refactor: introduce transport-neutral connection runtime

This commit is contained in:
caixypromise
2026-07-27 02:05:26 +08:00
parent f5ed1aaec8
commit 0c582ed3b6
56 changed files with 9931 additions and 237 deletions
@@ -0,0 +1,132 @@
import json
import uuid
from typing import Any
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from core.components.component_manager import ComponentType
from config.logger import setup_logging
logger = setup_logging()
class AbortProcessor(MessageProcessor):
"""中断消息处理器:完整迁移abortMessageHandler.py和abortHandle.py的所有功能"""
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""处理abort类型的消息"""
msg_json = None
if isinstance(message, str):
try:
msg_json = json.loads(message)
except json.JSONDecodeError:
msg_json = None
elif isinstance(message, dict):
msg_json = message
if isinstance(msg_json, dict) and msg_json.get("type") == "abort":
await self.handle_abort_message(context, transport, msg_json)
return True
return False
async def handle_abort_message(self, context: SessionContext, transport: TransportInterface, msg_json: dict):
"""处理中断消息 - 完整迁移自abortHandle.py的handleAbortMessage"""
msg_session_id = msg_json.get("session_id")
if msg_session_id and msg_session_id != context.session_id:
logger.warning(
f"忽略非当前会话的abort消息: "
f"msg={msg_session_id}, current={context.session_id}"
)
return
logger.info("Abort message received")
# 设置成打断状态,会自动打断llm、tts任务 - 完整迁移原逻辑
context.abort_requested = True
context.close_after_chat = False
# Stop the old LLM/tool producers before a subsequent listen/start can
# clear abort_requested and begin a new turn.
cancel_tasks = getattr(context, "cancel_turn_tasks", None)
if callable(cancel_tasks):
await cancel_tasks()
# 清理队列 - 完整迁移原逻辑
await self._clear_queues(context)
# 打断客户端说话状态 - 完整迁移原逻辑
await transport.send_json({
"type": "tts",
"state": "stop",
"session_id": context.session_id
})
# 清理说话状态 - 完整迁移原逻辑
self._clear_speak_status(context)
logger.info("Abort message received-end")
async def _clear_queues(self, context: SessionContext):
"""清理所有队列 - 完整迁移原clear_queues逻辑"""
try:
# 清理TTS音频队列
tts_component = await self._get_component(context, ComponentType.TTS)
if tts_component and hasattr(tts_component, 'tts_instance'):
tts_instance = tts_component.tts_instance
for queue_name in ('tts_text_queue', 'tts_audio_queue'):
pending_queue = getattr(tts_instance, queue_name, None)
if pending_queue is None:
continue
try:
while not pending_queue.empty():
pending_queue.get_nowait()
except:
pass
# Rotate the turn token so late text/audio from the aborted turn is stale.
context.sentence_id = uuid.uuid4().hex
# 清理ASR音频队列
context.clear_audio_buffer()
# 清理其他可能的队列
if hasattr(context, 'clear_queues'):
context.clear_queues()
except Exception as e:
logger.error(f"清理队列时出错: {e}")
def _clear_speak_status(self, context: SessionContext):
"""清理说话状态 - 完整迁移原clearSpeakStatus逻辑"""
try:
# 清理说话状态
context.is_speaking = False
# 如果有其他说话状态相关的属性,也一并清理
if hasattr(context, 'clearSpeakStatus'):
context.clearSpeakStatus()
# 重置相关状态
context.client_have_voice = False
context.client_voice_stop = True
except Exception as e:
logger.error(f"清理说话状态时出错: {e}")
async def _send_abort_confirmation(self, transport: TransportInterface, session_id: str):
"""发送中断确认响应(可选)"""
response = {
"type": "abort",
"status": "success",
"message": "中断操作已完成",
"session_id": session_id
}
try:
await transport.send(json.dumps(response))
except Exception as e:
logger.error(f"发送中断确认响应失败: {e}")
async def _get_component(self, context: SessionContext, component_type: ComponentType):
if not context.component_manager:
return None
return await context.component_manager.get_component(component_type, context)
@@ -0,0 +1,439 @@
import time
import json
import asyncio
import uuid
from typing import Any
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from core.components.component_manager import ComponentType
from core.services.audio_ingress_service import AudioIngressService
from core.utils.util import audio_to_data
from core.utils.output_counter import check_device_output_limit
from config.logger import setup_logging
logger = setup_logging()
def arm_wake_audio_suppression(context: SessionContext) -> None:
"""Ignore reordered wake-word audio for a bounded interval."""
context.just_woken_up = True
previous = getattr(context, "wake_audio_suppression_task", None)
if previous and not previous.done():
previous.cancel()
delay_ms = max(
0,
int(context.config.get("wake_audio_suppression_ms", 2000)),
)
if delay_ms == 0:
context.just_woken_up = False
context.wake_audio_suppression_task = None
return
session_id = context.session_id
context.wake_audio_suppression_task = context.create_background_task(
_release_wake_audio_suppression(
context,
session_id,
delay_ms / 1000,
),
conversation_scoped=True,
)
async def _release_wake_audio_suppression(
context: SessionContext,
session_id: str,
delay_seconds: float,
) -> None:
current_task = asyncio.current_task()
try:
await asyncio.sleep(delay_seconds)
if context.session_id == session_id:
context.just_woken_up = False
finally:
if getattr(context, "wake_audio_suppression_task", None) is current_task:
context.wake_audio_suppression_task = None
class AudioReceiveProcessor(MessageProcessor):
"""音频接收处理器:完整迁移receiveAudioHandle.py的所有功能"""
def __init__(self, audio_ingress_service=None):
self.audio_ingress_service = audio_ingress_service or AudioIngressService()
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""处理音频消息"""
if isinstance(message, bytes):
await self.handle_audio_message(context, transport, message, 0)
return True
if isinstance(message, dict) and message.get("type") == "audio":
audio_data = message.get("data")
if isinstance(audio_data, (bytes, bytearray)):
await self.handle_audio_message(
context,
transport,
bytes(audio_data),
int(message.get("timestamp", 0) or 0),
)
return True
return True
return False
async def handle_audio_message(
self,
context: SessionContext,
transport: TransportInterface,
audio: bytes,
timestamp: int = 0,
):
"""处理音频消息 - 完整迁移自handleAudioMessage"""
if (
getattr(transport, "requires_audio_tail_grace", False)
and not getattr(context, "accepting_input_audio", False)
):
return
pcm_frame = self.audio_ingress_service.process(context, audio, timestamp)
if not pcm_frame:
return
# Do not feed encoded wake-word history into stateful VAD. Native MQTT
# gates the usual pre-start history above; this bounded suppression only
# covers frames that crossed the independent control/audio channels.
if context.just_woken_up:
if not getattr(context, "wake_audio_suppression_task", None):
arm_wake_audio_suppression(context)
context.asr_audio.clear()
context.reset_vad_states()
return
# 获取VAD组件
vad_component = await self._get_component(context, ComponentType.VAD)
if not vad_component or not hasattr(vad_component, 'vad_instance'):
now = time.time()
last_log = getattr(context, "_vad_missing_last_log", 0.0)
if now - last_log > 10:
logger.warning("VAD组件未初始化")
context._vad_missing_last_log = now
return
vad_instance = vad_component.vad_instance
# 当前片段是否有人说话
have_voice = vad_instance.is_vad(context, pcm_frame)
if have_voice:
if (
getattr(context, "client_aec", False)
and context.is_speaking
and context.listen_mode != "manual"
):
await self._handle_abort_message(context, transport)
# 设备长时间空闲检测,用于say goodbye
await self._no_voice_close_connect(context, transport, have_voice)
# 接收音频
asr_component = await self._get_component(context, ComponentType.ASR)
asr_instance = asr_component.asr_instance if asr_component and hasattr(asr_component, 'asr_instance') else None
# 自动模式兜底:没有收到 listen stop 时,基于VAD触发一次停止
if (
not have_voice
and context.client_have_voice
and not context.client_voice_stop
and not getattr(context, "listen_stop_pending", False)
and context.listen_mode != "manual"
and asr_instance is not None
):
context.client_voice_stop = True
try:
from core.providers.asr.dto.dto import InterfaceType
if hasattr(asr_instance, "interface_type") and asr_instance.interface_type == InterfaceType.STREAM:
if hasattr(asr_instance, "_send_stop_request"):
context.create_background_task(
asr_instance._send_stop_request(),
turn_scoped=True,
)
else:
if len(context.asr_audio) > 0:
asr_audio_task = context.asr_audio.copy()
context.asr_audio.clear()
context.reset_vad_states()
await asr_instance.handle_voice_stop(context, asr_audio_task)
except Exception as e:
logger.error(f"自动VAD停止处理失败: {e}")
if asr_instance and hasattr(asr_instance, 'receive_audio'):
await asr_instance.receive_audio(context, pcm_frame, have_voice)
async def start_to_chat(
self,
context: SessionContext,
transport: TransportInterface,
text: str,
*,
preserve_close_after_chat: bool = False,
):
"""开始聊天 - 完整迁移自startToChat"""
# 检查输入是否是JSON格式(包含说话人信息)
speaker_name = None
actual_text = text
try:
# 尝试解析JSON格式的输入
if text.strip().startswith('{') and text.strip().endswith('}'):
data = json.loads(text)
if 'speaker' in data and 'content' in data:
speaker_name = data['speaker']
actual_content = data['content']
logger.info(f"解析到说话人信息: {speaker_name}")
if speaker_name not in context.introduced_speakers:
context.introduced_speakers.add(speaker_name)
actual_text = text
else:
actual_text = actual_content
except (json.JSONDecodeError, KeyError):
# 如果解析失败,继续使用原始文本
pass
# 保存说话人信息到上下文
if speaker_name:
context.current_speaker = speaker_name
else:
context.current_speaker = None
# 检查设备绑定
if context.need_bind:
await self.prompt_bind_device(context, transport)
return
# 如果当日的输出字数大于限定的字数
if context.max_output_size > 0:
if check_device_output_limit(
context.headers.get("device-id"), context.max_output_size
):
await self._max_out_size(context, transport)
return
if context.is_speaking and getattr(context, "listen_mode", "auto") != "manual":
await self._handle_abort_message(context, transport)
context.abort_requested = False
if not preserve_close_after_chat:
context.close_after_chat = False
# 首先进行意图分析,使用实际文本内容
from core.processors.chat_processor import ChatProcessor
chat_processor = ChatProcessor()
intent_handled = await chat_processor.handle_user_intent(context, transport, actual_text)
if intent_handled:
# 如果意图已被处理,不再进行聊天
return
# 意图未被处理,继续常规聊天流程,使用实际文本内容
await self._send_stt_message(context, transport, actual_text)
# 与旧架构一致:将聊天处理放入线程池,避免阻塞事件循环
from core.processors.chat_processor import ChatProcessor
chat_processor = ChatProcessor()
if hasattr(context, "create_background_task"):
context.create_background_task(
chat_processor.handle_chat(
context,
transport,
actual_text,
skip_intent=True,
),
turn_scoped=True,
)
else:
await chat_processor.handle_chat(
context, transport, actual_text, skip_intent=True
)
async def _no_voice_close_connect(self, context: SessionContext, transport: TransportInterface, have_voice: bool):
"""无声音时关闭连接检测 - 完整迁移自no_voice_close_connect"""
if have_voice:
context.update_activity()
return
# 只有在已经初始化过时间戳的情况下才进行超时检查
if context.last_activity_time_ms > 0.0:
no_voice_time = time.time() * 1000 - context.last_activity_time_ms
close_connection_no_voice_time = int(
context.config.get("close_connection_no_voice_time", 120)
)
if (
not context.close_after_chat
and no_voice_time > 1000 * close_connection_no_voice_time
):
context.close_after_chat = True
context.abort_requested = False
end_prompt = context.config.get("end_prompt", {})
if end_prompt and end_prompt.get("enable", True) is False:
logger.info("结束对话,无需发送结束提示语")
if transport.keeps_connection_between_sessions:
session_id = context.session_id
end_conversation = getattr(context, "end_conversation", None)
if callable(end_conversation):
await end_conversation(session_id)
await transport.end_session(session_id)
context.close_after_chat = False
context.reset_vad_states()
else:
await transport.close()
return
prompt = end_prompt.get("prompt")
if not prompt:
prompt = "请你以```时间过得真快```未来头,用富有感情、依依不舍的话来结束这场对话吧。!"
await self.start_to_chat(
context,
transport,
prompt,
preserve_close_after_chat=True,
)
async def _max_out_size(self, context: SessionContext, transport: TransportInterface):
"""超出最大输出字数处理 - 完整迁移自max_out_size"""
# 播放超出最大输出字数的提示
context.abort_requested = False
text = "不好意思,我现在有点事情要忙,明天这个时候我们再聊,约好了哦!明天不见不散,拜拜!"
await self._send_stt_message(context, transport, text)
file_path = "config/assets/max_output_size.wav"
opus_packets = await audio_to_data(file_path)
# 获取TTS组件并添加到队列
tts_component = await self._get_component(context, ComponentType.TTS)
if tts_component and hasattr(tts_component, 'tts_instance'):
tts_instance = tts_component.tts_instance
if hasattr(tts_instance, 'tts_audio_queue'):
from core.providers.tts.dto.dto import SentenceType
tts_instance.tts_audio_queue.put(
(SentenceType.LAST, opus_packets, text, context.sentence_id)
)
context.close_after_chat = True
async def prompt_bind_device(
self,
context: SessionContext,
transport: TransportInterface,
):
"""检查设备绑定 - 完整迁移自check_bind_device"""
bind_code = context.bind_code
if bind_code:
# 确保bind_code是6位数字
if len(bind_code) != 6:
logger.error(f"无效的绑定码格式: {bind_code}")
text = "绑定码格式错误,请检查配置。"
await self._send_stt_message(context, transport, text)
return
text = f"请登录控制面板,输入{bind_code},绑定设备。"
await self._send_stt_message(context, transport, text)
# 获取TTS组件
tts_component = await self._get_component(context, ComponentType.TTS)
if not tts_component or not hasattr(tts_component, 'tts_instance'):
return
tts_instance = tts_component.tts_instance
if not hasattr(tts_instance, 'tts_audio_queue'):
return
# 播放提示音
from core.providers.tts.dto.dto import SentenceType
music_path = "config/assets/bind_code.wav"
opus_packets = await audio_to_data(music_path)
tts_instance.tts_audio_queue.put(
(SentenceType.FIRST, opus_packets, text, context.sentence_id)
)
# 逐个播放数字
for i in range(6): # 确保只播放6位数字
try:
digit = bind_code[i]
num_path = f"config/assets/bind_code/{digit}.wav"
num_packets = await audio_to_data(num_path)
tts_instance.tts_audio_queue.put(
(SentenceType.MIDDLE, num_packets, None, context.sentence_id)
)
except Exception as e:
logger.error(f"播放数字音频失败: {e}")
continue
tts_instance.tts_audio_queue.put(
(SentenceType.LAST, [], None, context.sentence_id)
)
else:
# 播放未绑定提示
context.abort_requested = False
text = f"没有找到该设备的版本信息,请正确配置 OTA地址,然后重新编译固件。"
await self._send_stt_message(context, transport, text)
# 获取TTS组件
tts_component = await self._get_component(context, ComponentType.TTS)
if tts_component and hasattr(tts_component, 'tts_instance'):
tts_instance = tts_component.tts_instance
if hasattr(tts_instance, 'tts_audio_queue'):
from core.providers.tts.dto.dto import SentenceType
music_path = "config/assets/bind_not_found.wav"
opus_packets = await audio_to_data(music_path)
tts_instance.tts_audio_queue.put(
(SentenceType.LAST, opus_packets, text, context.sentence_id)
)
async def _handle_abort_message(self, context: SessionContext, transport: TransportInterface):
"""处理中断消息"""
from core.processors.abort_processor import AbortProcessor
await AbortProcessor().handle_abort_message(
context,
transport,
{"type": "abort", "session_id": context.session_id},
)
async def _clear_queues(self, context: SessionContext):
"""清理所有队列"""
# 清理TTS音频队列
tts_component = await self._get_component(context, ComponentType.TTS)
if tts_component and hasattr(tts_component, 'tts_instance'):
tts_instance = tts_component.tts_instance
for queue_name in ('tts_text_queue', 'tts_audio_queue'):
pending_queue = getattr(tts_instance, queue_name, None)
if pending_queue is None:
continue
try:
while not pending_queue.empty():
pending_queue.get_nowait()
except Exception:
pass
# Invalidate late TTS text/audio generated by the interrupted turn.
context.sentence_id = uuid.uuid4().hex
# 清理ASR音频队列
context.clear_audio_buffer()
async def _get_component(self, context: SessionContext, component_type: ComponentType):
if not context.component_manager:
return None
return await context.component_manager.get_component(component_type, context)
async def _send_stt_message(self, context: SessionContext, transport: TransportInterface, text: str):
"""发送STT消息"""
from core.processors.audio_send_processor import AudioSendProcessor
await AudioSendProcessor().send_stt_message(
context, transport, text
)
@@ -0,0 +1,578 @@
import asyncio
import json
import time
from typing import Any, List
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from core.providers.tts.dto.dto import SentenceType
from core.utils import textUtils
from core.components.component_manager import ComponentType
from core.services.audio_ingress_service import AudioIngressService
from config.logger import setup_logging
logger = setup_logging()
class AudioSendProcessor(MessageProcessor):
"""音频发送处理器:完整迁移sendAudioHandle.py的所有功能"""
def __init__(self, audio_ingress_service=None):
self.audio_ingress_service = audio_ingress_service or AudioIngressService()
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""这个处理器不直接处理消息,而是被其他处理器调用"""
return False
async def send_audio_message(self, context: SessionContext, transport: TransportInterface,
sentence_type: SentenceType, audios: bytes, text: str,
sentence_id: str = None):
"""发送音频消息 - 完整迁移自sendAudioMessage"""
if not self._is_current_turn(context, sentence_id):
return
tts_component = await self._get_component(context, ComponentType.TTS)
if not tts_component or not hasattr(tts_component, 'tts_instance'):
return
tts_instance = tts_component.tts_instance
if hasattr(tts_instance, 'tts_audio_first_sentence') and tts_instance.tts_audio_first_sentence:
logger.info(f"发送第一段语音: {text}")
tts_instance.tts_audio_first_sentence = False
await self.start_tts_stream(context, transport)
if sentence_type == SentenceType.FIRST:
await self.send_tts_message(
context, transport, "sentence_start", text, sentence_id=sentence_id
)
await self.send_audio(
context, transport, audios, sentence_id=sentence_id
)
if not self._is_current_turn(context, sentence_id):
return
# 发送句子开始消息
if sentence_type is not SentenceType.MIDDLE:
logger.info(f"发送音频消息: {sentence_type}, {text}")
# 发送结束消息(如果是最后一个文本)
if (
not getattr(context, "calling", False)
and context.llm_finish_task
and sentence_type == SentenceType.LAST
):
# Latch the terminal action before sending tts:stop. The device can
# immediately answer with listen:start, whose handler resets the
# mutable turn flags while this coroutine is still awaiting I/O.
close_after_chat = bool(context.close_after_chat)
closing_session_id = (
getattr(transport, "session_id", None) or context.session_id
)
if close_after_chat:
context.close_after_chat = False
await self.send_tts_message(
context, transport, "stop", None, sentence_id=sentence_id
)
if not self._is_current_turn(context, sentence_id):
return
context.is_speaking = False
if close_after_chat:
# MQTT/UDP:结束后回到Idle,不关闭连接
if transport.keeps_connection_between_sessions:
end_conversation = getattr(context, "end_conversation", None)
if callable(end_conversation):
await end_conversation(closing_session_id)
await transport.end_session(closing_session_id)
else:
await transport.close()
async def send_audio(
self,
context: SessionContext,
transport: TransportInterface,
audios: bytes,
frame_duration: int = 60,
sentence_id: str = None,
):
"""发送单个opus包,支持流控 - 完整迁移自sendAudio"""
if audios is None or len(audios) == 0:
return
if getattr(context, "audio_flow_control", {}).get("send_failed"):
return
# MQTT/UDP:等待UDP远端地址就绪,避免首包丢失
if transport.has_datagram_audio:
if not await transport.wait_audio_ready(timeout=2):
logger.warning("UDP远端地址未就绪,跳过音频发送")
return
audio_list = [audios] if isinstance(audios, bytes) else audios
if not isinstance(audio_list, list):
return
flow = context.audio_flow_control
pre_buffer_count = max(
0, int(context.config.get("tts_pre_buffer_count", 5))
)
for audio in audio_list:
if context.abort_requested or not self._is_current_turn(context, sentence_id):
break
# Match the legacy AudioRateController contract: send only the
# initial pre-buffer inline, then enqueue the remaining frames so
# the TTS consumer can prefetch later sentence chunks.
packet_count = int(flow.get("packet_count", 0))
if packet_count < pre_buffer_count and "_send_queue" not in flow:
sent = await self._send_audio_packet(
context,
transport,
flow,
audio,
frame_duration,
sentence_id,
paced=False,
)
if not sent:
break
continue
queue = self._ensure_audio_sender(
context, transport, flow, sentence_id
)
await queue.put(("audio", audio, frame_duration, sentence_id))
@staticmethod
async def _pace_audio_send(
context: SessionContext,
frame_duration: int,
flow: dict = None,
) -> None:
flow = flow if flow is not None else context.audio_flow_control
packet_count = int(flow.get("packet_count", 0))
pre_buffer_count = max(0, int(context.config.get("tts_pre_buffer_count", 5)))
if packet_count < pre_buffer_count:
return
configured_delay = int(context.config.get("tts_audio_send_delay", -1))
if configured_delay > 0:
await asyncio.sleep(configured_delay / 1000.0)
return
delay_ms = max(0, int(frame_duration))
if delay_ms <= 0:
return
now = time.monotonic()
# packet_count is the number already sent. The first packet after the
# pre-buffer must therefore wait one complete frame, not become an
# extra immediate packet.
paced_packet_index = packet_count - pre_buffer_count + 1
pacing_started_at = flow.get("pacing_started_at")
pacing_delay_ms = flow.get("pacing_delay_ms")
if pacing_started_at is None or pacing_delay_ms != delay_ms:
pacing_started_at = now
flow["pacing_started_at"] = pacing_started_at
flow["pacing_delay_ms"] = delay_ms
delay_seconds = delay_ms / 1000.0
target = pacing_started_at + paced_packet_index * delay_seconds
# TTS producers can pause between sentence chunks. Rebase after a
# full-frame gap instead of bursting stale deadlines to catch up.
if now - target >= delay_seconds:
pacing_started_at = now - paced_packet_index * delay_seconds
flow["pacing_started_at"] = pacing_started_at
target = now
wait_seconds = target - now
if wait_seconds > 0:
await asyncio.sleep(wait_seconds)
def _ensure_audio_sender(
self,
context: SessionContext,
transport: TransportInterface,
flow: dict,
sentence_id: str = None,
) -> asyncio.Queue:
queue = flow.get("_send_queue")
task = flow.get("_send_task")
if queue is not None and task is not None and not task.done():
return queue
queue_size = max(
1, int(context.config.get("tts_audio_queue_size", 256))
)
queue = asyncio.Queue(maxsize=queue_size)
sender = self._audio_send_loop(
context, transport, flow, queue, sentence_id
)
create_task = getattr(context, "create_background_task", None)
if callable(create_task):
task = create_task(sender, turn_scoped=True)
else:
task = asyncio.create_task(sender)
flow["_send_queue"] = queue
flow["_send_task"] = task
return queue
async def _audio_send_loop(
self,
context: SessionContext,
transport: TransportInterface,
flow: dict,
queue: asyncio.Queue,
sentence_id: str = None,
) -> None:
try:
while True:
item = await queue.get()
try:
item_type = item[0]
if not self._is_current_turn(context, sentence_id):
continue
if item_type == "audio":
_, audio, frame_duration, item_sentence_id = item
await self._send_audio_packet(
context,
transport,
flow,
audio,
frame_duration,
item_sentence_id,
paced=True,
)
elif item_type == "json":
_, message, item_sentence_id = item
if self._is_current_turn(context, item_sentence_id):
await transport.send_json(message)
finally:
queue.task_done()
except asyncio.CancelledError:
raise
except Exception as error:
flow["send_failed"] = True
logger.error("后台音频发送循环失败: {}", error)
finally:
while True:
try:
queue.get_nowait()
except asyncio.QueueEmpty:
break
else:
queue.task_done()
async def _send_audio_packet(
self,
context: SessionContext,
transport: TransportInterface,
flow: dict,
audio: bytes,
frame_duration: int,
sentence_id: str = None,
*,
paced: bool,
) -> bool:
if paced:
await self._pace_audio_send(context, frame_duration, flow)
if context.abort_requested or not self._is_current_turn(context, sentence_id):
return False
context.update_activity()
timestamp = self._next_audio_timestamp(
context, frame_duration, flow
)
sent = await self._send_audio_with_retry(
context,
transport,
audio,
timestamp,
sentence_id,
flow,
)
if not sent or not self._is_current_turn(context, sentence_id):
return False
self.audio_ingress_service.cache_output_reference(
context, audio, timestamp
)
flow["packet_count"] = int(flow.get("packet_count", 0)) + 1
return True
async def _send_audio_with_retry(
self,
context: SessionContext,
transport: TransportInterface,
audio: bytes,
timestamp: int,
sentence_id: str = None,
flow: dict = None,
) -> bool:
flow = flow if flow is not None else context.audio_flow_control
retries = max(0, int(context.config.get("audio_send_retries", 2)))
retry_delay = max(
0, int(context.config.get("audio_send_retry_delay_ms", 20))
) / 1000.0
for attempt in range(retries + 1):
if context.abort_requested or not self._is_current_turn(context, sentence_id):
return False
try:
await transport.send_audio(audio, timestamp)
return self._is_current_turn(context, sentence_id)
except Exception as error:
if attempt >= retries:
flow["send_failed"] = True
logger.error(
"Audio send failed after {} attempts: {}",
attempt + 1,
error,
)
raise
await asyncio.sleep(retry_delay)
return False
@staticmethod
def _next_audio_timestamp(
context: SessionContext,
frame_duration: int,
flow: dict = None,
) -> int:
flow = flow if flow is not None else getattr(
context, "audio_flow_control", None
)
if flow is None:
flow = {}
context.audio_flow_control = flow
timestamp_step = int(frame_duration) or int(
getattr(context, "output_frame_duration", 60) or 60
)
timestamp = int(flow.get("output_timestamp", 0)) + timestamp_step
timestamp %= 2 ** 32
flow["output_timestamp"] = timestamp
return timestamp
async def send_stt_message(self, context: SessionContext, transport: TransportInterface, text: str):
"""发送STT消息 - 完整迁移自send_stt_message"""
end_prompt = getattr(context, "config", {}).get("end_prompt", {}).get("prompt")
if end_prompt and text == end_prompt:
await self.start_tts_stream(context, transport)
return
display_text = text
parsed_data = None
if isinstance(text, dict):
parsed_data = text
elif isinstance(text, str):
stripped = text.strip()
if stripped.startswith("{") and stripped.endswith("}"):
try:
parsed_data = json.loads(stripped)
except json.JSONDecodeError:
pass
if isinstance(parsed_data, dict) and "content" in parsed_data:
display_text = parsed_data["content"]
if "speaker" in parsed_data:
context.current_speaker = parsed_data["speaker"]
stt_text = textUtils.get_string_no_punctuation_or_emoji(
str(display_text)
)
await self._send_json(transport, {
"type": "stt",
"text": stt_text,
"session_id": context.session_id
})
logger.info(f"发送STT消息: {stt_text}")
# Legacy WS sends TTS start together with STT, before synthesis begins.
# This lead time is required by MQTT/UDP clients because the device
# changes to Speaking asynchronously and drops UDP audio beforehand.
await self.start_tts_stream(context, transport)
async def start_tts_stream(
self,
context: SessionContext,
transport: TransportInterface,
) -> bool:
"""Open one device-side TTS stream without sending duplicate starts."""
if getattr(context, "is_speaking", False):
return False
context.is_speaking = True
flow_control = getattr(context, "audio_flow_control", None) or {}
await self._stop_audio_sender(flow_control)
context.audio_flow_control = {}
try:
await self._send_json(transport, {
"type": "tts",
"state": "start",
"session_id": context.session_id,
})
except Exception:
context.is_speaking = False
raise
return True
@staticmethod
async def _send_json(
transport: TransportInterface,
message: dict,
) -> None:
send_json = getattr(transport, "send_json", None)
if callable(send_json):
await send_json(message)
return
await transport.send(json.dumps(message))
async def send_tts_message(
self,
context: SessionContext,
transport: TransportInterface,
state: str,
text: str = None,
sentence_id: str = None,
):
"""发送TTS消息 - 完整迁移自send_tts_message"""
if not self._is_current_turn(context, sentence_id):
return False
if state == "sentence_start":
flow = getattr(context, "audio_flow_control", {})
queue = flow.get("_send_queue")
task = flow.get("_send_task")
if queue is not None and task is not None and not task.done():
message = {
"type": "tts",
"state": state,
"session_id": context.session_id,
}
if text:
message["text"] = text
await queue.put(("json", message, sentence_id))
return True
if state == "stop":
if context.config.get("enable_stop_tts_notify", False):
from core.utils.util import audio_to_data
notify_path = context.config.get(
"stop_tts_notify_voice", "config/assets/tts_notify.mp3"
)
notify_audio = await audio_to_data(notify_path, is_opus=True)
if notify_audio:
await self.send_audio(
context,
transport,
notify_audio,
sentence_id=sentence_id,
)
if not await self._wait_for_audio_completion(context, sentence_id):
return False
if not self._is_current_turn(context, sentence_id):
return False
message = {
"type": "tts",
"state": state,
"session_id": context.session_id
}
if text:
message["text"] = text
await transport.send_json(message)
logger.debug(f"发送TTS消息: state={state}, text={text}")
return True
@staticmethod
async def _wait_for_audio_completion(
context: SessionContext, sentence_id: str = None
) -> bool:
flow = context.audio_flow_control
queue = flow.get("_send_queue")
sender_task = flow.get("_send_task")
if queue is not None and sender_task is not None:
join_task = asyncio.create_task(queue.join())
done, _ = await asyncio.wait(
{join_task, sender_task},
return_when=asyncio.FIRST_COMPLETED,
)
if sender_task in done and not join_task.done():
join_task.cancel()
await asyncio.gather(join_task, return_exceptions=True)
return False
await join_task
await AudioSendProcessor._stop_audio_sender(flow)
if flow.get("send_failed"):
return False
packet_count = int(flow.get("packet_count", 0))
if packet_count <= 0:
return AudioSendProcessor._is_current_turn(context, sentence_id)
frame_duration = max(1, int(getattr(context, "output_frame_duration", 60)))
pre_buffer_count = max(0, int(context.config.get("tts_pre_buffer_count", 5)))
tail_frames = max(
0,
int(context.config.get("tts_stop_buffer_frames", pre_buffer_count + 2)),
)
if tail_frames:
await asyncio.sleep(tail_frames * frame_duration / 1000.0)
return AudioSendProcessor._is_current_turn(context, sentence_id)
@staticmethod
async def _stop_audio_sender(flow: dict) -> None:
task = flow.pop("_send_task", None)
flow.pop("_send_queue", None)
if task is not None and not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
@staticmethod
def _is_current_turn(context: SessionContext, sentence_id: str = None) -> bool:
if sentence_id is None:
return bool(
getattr(context, "conversation_active", True)
and getattr(context, "sentence_id", None) is None
)
return sentence_id == context.sentence_id
async def send_music_message(self, context: SessionContext, transport: TransportInterface,
music_path: str, text: str):
"""发送音乐消息 - 完整迁移自send_music_message"""
from core.utils.util import audio_to_data
try:
# 获取音频数据
opus_packets = await audio_to_data(music_path)
if opus_packets:
# 发送音乐开始消息
await self.send_tts_message(context, transport, "start", text)
# 发送音频数据
await self.send_audio(context, transport, opus_packets)
# 发送音乐结束消息
await self.send_tts_message(context, transport, "stop", None)
logger.info(f"发送音乐: {music_path}")
else:
logger.warning(f"无法加载音乐文件: {music_path}")
except Exception as e:
logger.error(f"发送音乐失败: {e}")
async def _get_component(self, context: SessionContext, component_type: ComponentType):
if not context.component_manager:
return None
return await context.component_manager.get_component(component_type, context)
async def send_welcome_audio(self, context: SessionContext, transport: TransportInterface):
"""发送欢迎音频"""
welcome_audio_path = context.config.get("welcome_audio_path")
if welcome_audio_path:
await self.send_music_message(context, transport, welcome_audio_path, "欢迎使用小智助手")
async def send_goodbye_audio(self, context: SessionContext, transport: TransportInterface):
"""发送告别音频"""
goodbye_audio_path = context.config.get("goodbye_audio_path")
if goodbye_audio_path:
await self.send_music_message(context, transport, goodbye_audio_path, "再见,期待下次相遇")
@@ -0,0 +1,55 @@
from typing import Any
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from core.auth import AuthMiddleware, AuthenticationError
from config.logger import setup_logging
logger = setup_logging()
class AuthProcessor(MessageProcessor):
"""认证处理器:处理连接认证逻辑"""
def __init__(self):
self.auth_middleware = None
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""处理认证相关逻辑"""
# 服务器管理连接走server消息处理(基于secret校验),跳过认证
if isinstance(message, str):
try:
import json
msg_json = json.loads(message)
if isinstance(msg_json, dict) and msg_json.get("type") == "server":
return False
except json.JSONDecodeError:
pass
elif isinstance(message, dict) and message.get("type") == "server":
return False
# 如果已经认证,跳过
if context.is_authenticated:
return False
# 初始化认证中间件(延迟初始化)
if self.auth_middleware is None:
self.auth_middleware = AuthMiddleware(context.config)
# 检查是否为认证消息(通过headers进行认证)
if context.headers:
try:
await self.auth_middleware.authenticate_async(context.headers)
context.is_authenticated = True
logger.info(f"设备认证成功: {context.device_id}")
return False # 认证成功,继续处理其他消息
except AuthenticationError as e:
logger.error(f"设备认证失败: {e}")
# 发送认证失败消息
await transport.send("Authentication failed")
await transport.close()
return True # 认证失败,停止处理
# 如果没有认证信息,要求认证
await transport.send("Authentication required")
return True # 停止后续处理
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
import json
from typing import Any
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from config.logger import setup_logging
logger = setup_logging()
class GoodbyeProcessor(MessageProcessor):
"""Goodbye消息处理器:处理会话结束"""
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
msg_json = None
if isinstance(message, str):
try:
msg_json = json.loads(message)
except json.JSONDecodeError:
msg_json = None
elif isinstance(message, dict):
msg_json = message
if isinstance(msg_json, dict) and msg_json.get("type") == "goodbye":
logger.info(f"收到goodbye: session_id={msg_json.get('session_id')}")
# 长连接传输仅结束逻辑会话,短连接传输关闭连接。
if transport.keeps_connection_between_sessions:
end_conversation = getattr(context, "end_conversation", None)
if callable(end_conversation):
await end_conversation(msg_json.get("session_id"))
else:
await transport.close()
return True
return False
@@ -0,0 +1,351 @@
import time
import json
import random
import asyncio
import uuid
from typing import Any
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from core.utils.dialogue import Message
from core.utils.util import audio_to_data, remove_punctuation_and_length, opus_datas_to_wav_bytes
from core.providers.tts.dto.dto import SentenceType
from core.processors.audio_receive_processor import arm_wake_audio_suppression
from core.utils.wakeup_word import WakeupWordsConfig
from core.components.component_manager import ComponentType
from core.providers.tools.device_mcp import (
MCPClient,
send_mcp_initialize_message,
)
from config.logger import setup_logging
logger = setup_logging()
# 唤醒词配置
WAKEUP_CONFIG = {
"refresh_time": 5,
"words": ["你好", "你好啊", "嘿,你好", ""],
}
# 创建全局的唤醒词配置管理器
wakeup_words_config = WakeupWordsConfig()
# 用于防止并发调用wakeupWordsResponse的锁
_wakeup_response_lock = asyncio.Lock()
class HelloProcessor(MessageProcessor):
"""Hello消息处理器:完整迁移helloHandle.py的所有功能"""
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""处理hello类型的消息"""
msg_json = None
if isinstance(message, str):
try:
msg_json = json.loads(message)
except json.JSONDecodeError:
msg_json = None
elif isinstance(message, dict):
msg_json = message
if isinstance(msg_json, dict) and msg_json.get("type") == "hello":
await self.handle_hello_message(context, transport, msg_json)
return True
return False
async def handle_hello_message(self, context: SessionContext, transport: TransportInterface, msg_json: dict):
"""处理hello消息 - 完整迁移自handleHelloMessage"""
pending_tasks = []
for task_name in (
"listen_stop_task",
"listen_start_task",
):
pending_task = getattr(context, task_name, None)
if pending_task and not pending_task.done():
pending_task.cancel()
pending_tasks.append(pending_task)
setattr(context, task_name, None)
if pending_tasks:
await asyncio.gather(*pending_tasks, return_exceptions=True)
transport_session_id = getattr(transport, "session_id", None)
if transport_session_id:
if (
context.session_id != transport_session_id
and callable(getattr(context, "end_conversation", None))
):
# A previous finalizer may already have published
# conversation_active=False while persistence is still in
# progress. Always join it before exposing the new session.
await context.end_conversation(context.session_id)
context.session_id = transport_session_id
if context.welcome_msg:
context.welcome_msg["session_id"] = context.session_id
dialogue = getattr(context, "dialogue", None)
if getattr(context, "prompt", None) and dialogue and not dialogue.dialogue:
dialogue.update_system_message(context.prompt)
inject_fewshot = getattr(context, "inject_tool_call_fewshot", None)
if callable(inject_fewshot):
inject_fewshot()
context.conversation_active = True
context.listen_stop_pending = False
context.listen_stop_deadline = 0.0
if transport.requires_audio_tail_grace:
context.accepting_input_audio = False
# 处理音频参数
audio_params = msg_json.get("audio_params")
if audio_params:
format = audio_params.get("format")
logger.info(f"客户端音频格式: {format}")
context.audio_format = format
context.input_sample_rate = int(
audio_params.get("sample_rate", getattr(context, "input_sample_rate", 16000))
)
context.input_channels = int(
audio_params.get("channels", getattr(context, "input_channels", 1))
)
context.input_frame_duration = int(
audio_params.get(
"frame_duration", getattr(context, "input_frame_duration", 60)
)
)
# 处理客户端特性
features = msg_json.get("features") or {}
context.features = dict(features)
context.client_aec = bool(features.get("aec"))
mcp_enabled = bool(features.get("mcp"))
previous_mcp_client = getattr(context, "mcp_client", None)
if not mcp_enabled and previous_mcp_client:
initialize_task = getattr(
context, "mcp_initialize_task", None
)
if initialize_task and not initialize_task.done():
initialize_task.cancel()
await asyncio.gather(
initialize_task, return_exceptions=True
)
context.mcp_initialize_task = None
previous_mcp_cleanup = getattr(
context, "_mcp_cleanup_callback", None
)
if previous_mcp_cleanup:
context.unregister_cleanup(previous_mcp_cleanup)
context._mcp_cleanup_callback = None
if hasattr(previous_mcp_client, "close"):
await previous_mcp_client.close()
context.mcp_client = None
if features:
logger.info(f"客户端特性: {features}")
if mcp_enabled:
logger.info("客户端支持MCP")
if context.mcp_client is None:
context.mcp_client = MCPClient()
context._mcp_cleanup_callback = (
context.mcp_client.close
)
context.register_cleanup(
context._mcp_cleanup_callback
)
initialize_task = getattr(
context, "mcp_initialize_task", None
)
if (
not await context.mcp_client.is_ready()
and (
initialize_task is None
or initialize_task.done()
)
):
context.mcp_initialize_task = (
context.create_background_task(
send_mcp_initialize_message(
context, transport
),
conversation_scoped=False,
)
)
if features.get("aec"):
logger.info("客户端启用了服务端AEC")
# MQTT/UDP 场景已由MQTT连接层发送hello回复,避免重复发送websocket格式
if transport.has_datagram_audio:
return
# 发送欢迎消息
if context.welcome_msg:
await transport.send_json(context.welcome_msg)
else:
# 默认欢迎消息
welcome_msg = {
"type": "hello",
"session_id": context.session_id,
"version": 1,
"transport": "websocket"
}
await transport.send_json(welcome_msg)
async def check_wakeup_words(self, context: SessionContext, transport: TransportInterface, text: str) -> bool:
"""检查唤醒词 - 完整迁移自checkWakeupWords"""
enable_wakeup_words_response_cache = context.config.get("enable_wakeup_words_response_cache", False)
# 等待tts初始化,最多等待3秒
tts_component = await self._get_component(context, ComponentType.TTS)
start_time = time.time()
while time.time() - start_time < 3:
if tts_component and hasattr(tts_component, 'tts_instance'):
break
await asyncio.sleep(0.1)
else:
return False
if not enable_wakeup_words_response_cache:
return False
_, filtered_text = remove_punctuation_and_length(text)
if filtered_text not in context.config.get("wakeup_words", []):
return False
sentence_id = uuid.uuid4().hex
context.sentence_id = sentence_id
context.just_woken_up = True
await self._send_stt_message(context, transport, text)
# 获取当前音色
tts_instance = getattr(tts_component, 'tts_instance', None) if tts_component else None
voice = getattr(tts_instance, "voice", "default") if tts_instance else "default"
if not voice:
voice = "default"
# 获取唤醒词回复配置
response = wakeup_words_config.get_wakeup_response(voice)
if not response or not response.get("file_path"):
response = {
"voice": "default",
"file_path": "config/assets/wakeup_words.wav",
"time": 0,
"text": "哈啰啊,我是小智啦,声音好听的台湾女孩一枚,超开心认识你耶,最近在忙啥,别忘了给我来点有趣的料哦,我超爱听八卦的啦",
}
# 获取音频数据
opus_packets = await audio_to_data(response.get("file_path"))
# 播放唤醒词回复
context.abort_requested = False
context.llm_finish_task = True
if tts_instance is not None:
# A prior turn may have consumed the one-shot start marker. Cached
# playback is a new TTS stream and must always emit start first.
tts_instance.tts_audio_first_sentence = True
logger.info(f"播放唤醒词回复: {response.get('text')}")
try:
await self._send_audio_message(
context,
transport,
SentenceType.FIRST,
opus_packets,
response.get("text"),
sentence_id,
)
await self._send_audio_message(
context,
transport,
SentenceType.LAST,
[],
None,
sentence_id,
)
finally:
# Cached wake playback bypasses ListenProcessor, so it must arm
# the same bounded release used by the regular wake-word path.
arm_wake_audio_suppression(context)
# 补充对话
if context.dialogue:
context.dialogue.put(Message(role="assistant", content=response.get("text")))
# 检查是否需要更新唤醒词回复
if time.time() - response.get("time", 0) > WAKEUP_CONFIG["refresh_time"]:
if not _wakeup_response_lock.locked():
context.create_background_task(
self._wakeup_words_response(context, transport)
)
return True
async def _wakeup_words_response(self, context: SessionContext, transport: TransportInterface):
"""生成唤醒词回复 - 完整迁移自wakeupWordsResponse"""
tts_component = await self._get_component(context, ComponentType.TTS)
llm_component = await self._get_component(context, ComponentType.LLM)
tts_instance = getattr(tts_component, 'tts_instance', None) if tts_component else None
llm_instance = getattr(llm_component, 'llm_instance', None) if llm_component else None
if not tts_instance or not llm_instance or not hasattr(llm_instance, 'response_no_stream'):
return
try:
# 尝试获取锁,如果获取不到就返回
async with _wakeup_response_lock:
# 生成唤醒词回复
wakeup_word = random.choice(WAKEUP_CONFIG["words"])
question = (
"此刻用户正在和你说```"
+ wakeup_word
+ "```。\n请你根据以上用户的内容进行20-30字回复。要符合系统设置的角色情感和态度,不要像机器人一样说话。\n"
+ "请勿对这条内容本身进行任何解释和回应,请勿返回表情符号,仅返回对用户的内容的回复。"
)
result = await asyncio.to_thread(
llm_instance.response_no_stream,
context.config.get("prompt", ""),
question,
)
if not result or len(result) == 0:
return
# 生成TTS音频
tts_result = await asyncio.to_thread(tts_instance.to_tts, result)
if not tts_result:
return
# 获取当前音色
voice = getattr(tts_instance, "voice", "default")
wav_bytes = opus_datas_to_wav_bytes(tts_result, sample_rate=16000)
file_path = wakeup_words_config.generate_file_path(voice)
with open(file_path, "wb") as f:
f.write(wav_bytes)
# 更新配置
wakeup_words_config.update_wakeup_response(voice, file_path, result)
except Exception as e:
logger.error(f"生成唤醒词回复失败: {e}")
async def _send_stt_message(self, context: SessionContext, transport: TransportInterface, text: str):
"""发送STT消息"""
from core.processors.audio_send_processor import AudioSendProcessor
await AudioSendProcessor().send_stt_message(
context, transport, text
)
async def _send_audio_message(self, context: SessionContext, transport: TransportInterface,
sentence_type: SentenceType, audios: bytes, text: str,
sentence_id: str = None):
"""发送音频消息"""
# 这里应该调用AudioSendProcessor
from core.processors.audio_send_processor import AudioSendProcessor
audio_send_processor = AudioSendProcessor()
await audio_send_processor.send_audio_message(
context,
transport,
sentence_type,
audios,
text,
sentence_id=sentence_id,
)
async def _get_component(self, context: SessionContext, component_type: ComponentType):
if not context.component_manager:
return None
return await context.component_manager.get_component(component_type, context)
@@ -0,0 +1,124 @@
import json
from typing import Any
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from core.providers.tools.device_iot import handleIotStatus, handleIotDescriptors
from config.logger import setup_logging
logger = setup_logging()
class IotProcessor(MessageProcessor):
"""IoT消息处理器:完整迁移iotMessageHandler.py的所有功能"""
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""处理iot类型的消息"""
msg_json = None
if isinstance(message, str):
try:
msg_json = json.loads(message)
except json.JSONDecodeError:
msg_json = None
elif isinstance(message, dict):
msg_json = message
if isinstance(msg_json, dict) and msg_json.get("type") == "iot":
await self.handle_iot_message(context, transport, msg_json)
return True
return False
async def handle_iot_message(self, context: SessionContext, transport: TransportInterface, msg_json: dict):
"""处理IoT消息 - 完整迁移自iotMessageHandler.py"""
tasks = []
# 处理设备描述符 - 完整迁移原逻辑
if "descriptors" in msg_json:
logger.debug("处理IoT设备描述符")
task = context.create_background_task(
self._handle_iot_descriptors(context, transport, msg_json["descriptors"])
)
tasks.append(task)
# 处理设备状态 - 完整迁移原逻辑
if "states" in msg_json:
logger.debug("处理IoT设备状态")
task = context.create_background_task(
self._handle_iot_status(context, transport, msg_json["states"])
)
tasks.append(task)
# 如果没有有效的IoT数据
if not tasks:
logger.warning("IoT消息缺少descriptors或states字段")
await self._send_error_response(
transport,
context.session_id,
"IoT消息格式错误:缺少descriptors或states字段"
)
return
# 任务由 SessionContext 持有,结束会话时统一取消。
async def _handle_iot_descriptors(self, context: SessionContext, transport: TransportInterface, descriptors: Any):
"""处理IoT设备描述符 - 包装原handleIotDescriptors函数"""
try:
# 调用原有的handleIotDescriptors函数
# 注意:这里需要传入context而不是conn,因为handleIotDescriptors可能需要适配
await handleIotDescriptors(context, descriptors)
logger.debug("IoT设备描述符处理完成")
except Exception as e:
logger.error(f"处理IoT设备描述符失败: {e}", exc_info=True)
await self._send_error_response(
transport,
context.session_id,
f"IoT设备描述符处理失败: {str(e)}"
)
async def _handle_iot_status(self, context: SessionContext, transport: TransportInterface, states: Any):
"""处理IoT设备状态 - 包装原handleIotStatus函数"""
try:
# 调用原有的handleIotStatus函数
# 注意:这里需要传入context而不是conn,因为handleIotStatus可能需要适配
await handleIotStatus(context, states)
logger.debug("IoT设备状态处理完成")
except Exception as e:
logger.error(f"处理IoT设备状态失败: {e}", exc_info=True)
await self._send_error_response(
transport,
context.session_id,
f"IoT设备状态处理失败: {str(e)}"
)
async def _send_error_response(self, transport: TransportInterface, session_id: str, message: str):
"""发送IoT错误响应"""
response = {
"type": "iot",
"status": "error",
"message": message,
"session_id": session_id
}
try:
await transport.send(json.dumps(response))
except Exception as e:
logger.error(f"发送IoT错误响应失败: {e}")
async def _send_success_response(self, transport: TransportInterface, session_id: str,
message: str, data: dict = None):
"""发送IoT成功响应"""
response = {
"type": "iot",
"status": "success",
"message": message,
"session_id": session_id
}
if data:
response["data"] = data
try:
await transport.send(json.dumps(response))
except Exception as e:
logger.error(f"发送IoT成功响应失败: {e}")
@@ -0,0 +1,433 @@
import time
import json
import asyncio
import uuid
from typing import Any
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from core.utils.util import remove_punctuation_and_length
from core.utils.dialogue import Message
from core.components.component_manager import ComponentType
from core.providers.tts.dto.dto import ContentType, TTSMessageDTO, SentenceType
from core.processors.audio_receive_processor import arm_wake_audio_suppression
from config.logger import setup_logging
logger = setup_logging()
class ListenProcessor(MessageProcessor):
"""Listen消息处理器:完整迁移listenMessageHandler.py的所有功能"""
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""处理listen类型的消息"""
msg_json = None
if isinstance(message, str):
try:
msg_json = json.loads(message)
except json.JSONDecodeError:
msg_json = None
elif isinstance(message, dict):
msg_json = message
if isinstance(msg_json, dict) and msg_json.get("type") == "listen":
await self.handle_listen_message(context, transport, msg_json)
return True
return False
async def handle_listen_message(self, context: SessionContext, transport: TransportInterface, msg_json: dict):
"""处理listen消息 - 完整迁移自listenMessageHandler.py"""
msg_session_id = msg_json.get("session_id")
if msg_session_id and msg_session_id != context.session_id:
logger.warning(
f"忽略非当前会话的listen消息: "
f"msg={msg_session_id}, current={context.session_id}"
)
return
state = msg_json.get("state")
has_datagram_audio = getattr(
transport, "has_datagram_audio", False
)
if (
has_datagram_audio
and state in {"start", "detect"}
and not context.conversation_active
):
if transport.keeps_connection_between_sessions:
await transport.send_json(
{
"type": "goodbye",
"session_id": msg_session_id or context.session_id,
}
)
logger.info(
"忽略已结束会话的listen {}: session_id={}",
state,
context.session_id,
)
return
# 设置拾音模式
if "mode" in msg_json:
context.listen_mode = msg_json["mode"]
logger.debug(f"客户端拾音模式:{context.listen_mode}")
# 处理不同的状态
if state == "start":
pending_start = getattr(context, "listen_start_task", None)
if pending_start and not pending_start.done():
logger.debug("忽略尾包隔离期内的重复listen start")
return
tail_quarantine = await self._flush_pending_listen_stop(
context, transport
)
# 开始监听语音
logger.info(f"listen start: session_id={context.session_id}, mode={context.listen_mode}")
context.reset_audio_states()
if (
getattr(transport, "requires_audio_tail_grace", False)
and tail_quarantine > 0
):
context.accepting_input_audio = False
context.listen_start_task = context.create_background_task(
self._open_input_after_tail_quarantine(
context,
context.session_id,
tail_quarantine,
),
turn_scoped=True,
)
else:
context.accepting_input_audio = True
context.abort_requested = False
context.close_after_chat = False
logger.debug("开始语音监听")
elif state == "stop":
# 停止监听语音
logger.info(f"listen stop: session_id={context.session_id}")
context.client_have_voice = True
context.listen_stop_pending = True
pending_start = getattr(context, "listen_start_task", None)
if pending_start and not pending_start.done():
pending_start.cancel()
context.listen_start_task = None
if getattr(transport, "requires_audio_tail_grace", False):
pending = getattr(context, "listen_stop_task", None)
if pending and not pending.done():
logger.debug("忽略重复的listen stop")
return
delay_ms = max(
0,
min(
1000,
int(
context.config.get(
"mqtt_udp_tail_grace_ms", 180
)
),
),
)
context.listen_stop_deadline = (
time.monotonic() + delay_ms / 1000
)
context.listen_stop_task = context.create_background_task(
self._finalize_listen_stop(
context,
transport,
context.session_id,
delay_ms / 1000,
),
turn_scoped=True,
)
else:
context.listen_stop_deadline = 0.0
await self._finalize_listen_stop(
context,
transport,
context.session_id,
0,
)
logger.debug("停止语音监听")
elif state == "detect":
# 检测到文本输入
logger.info(f"listen detect: session_id={context.session_id}, text={msg_json.get('text')}")
context.client_have_voice = False
context.asr_audio.clear()
if "text" in msg_json:
context.update_activity()
original_text = msg_json["text"] # 保留原始文本
filtered_len, filtered_text = remove_punctuation_and_length(original_text)
if original_text.startswith("[device_call]"):
await self._handle_device_call(
context,
transport,
original_text[len("[device_call]"):].strip(),
)
return
# 识别是否是唤醒词
is_wakeup_words = filtered_text in context.config.get("wakeup_words", [])
# 是否开启唤醒词回复
enable_greeting = context.config.get("enable_greeting", True)
if is_wakeup_words and not enable_greeting:
# 如果是唤醒词,且关闭了唤醒词回复,就不用回答
# Native already drops wake history until listen/start.
# Keeping the greeting suppression window armed here would
# discard the beginning of the user's first real utterance.
await self._send_stt_message(context, transport, original_text)
await self._send_tts_message(context, transport, "stop", None)
context.is_speaking = False
elif is_wakeup_words:
# 处理唤醒词
self._arm_wake_audio_suppression(context)
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
await self._enqueue_asr_report(context, "嘿,你好呀", [])
await self._start_to_chat(context, transport, "嘿,你好呀", skip_intent=True)
else:
# 处理普通文本
# 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
await self._enqueue_asr_report(context, original_text, [])
# 否则需要LLM对文字内容进行答复
await self._start_to_chat(context, transport, original_text)
async def _flush_pending_listen_stop(
self,
context: SessionContext,
transport: TransportInterface,
) -> float:
"""Finalize buffered speech before a new listen/start resets the turn."""
pending = getattr(context, "listen_stop_task", None)
had_pending_stop = bool(getattr(context, "listen_stop_pending", False))
tail_quarantine = max(
0.0,
float(getattr(context, "listen_stop_deadline", 0.0) or 0.0)
- time.monotonic(),
)
if pending and not pending.done():
pending.cancel()
try:
await pending
except asyncio.CancelledError:
pass
context.listen_stop_task = None
if had_pending_stop:
await self._finalize_listen_stop(
context,
transport,
context.session_id,
0,
)
else:
context.listen_stop_pending = False
if tail_quarantine <= 0:
context.listen_stop_deadline = 0.0
return tail_quarantine
@staticmethod
async def _open_input_after_tail_quarantine(
context: SessionContext,
session_id: str,
delay_seconds: float,
) -> None:
current_task = asyncio.current_task()
try:
await asyncio.sleep(delay_seconds)
if (
context.session_id == session_id
and context.conversation_active
and not context.listen_stop_pending
):
context.accepting_input_audio = True
finally:
if getattr(context, "listen_start_task", None) is current_task:
context.listen_start_task = None
context.listen_stop_deadline = 0.0
async def _finalize_listen_stop(
self,
context: SessionContext,
transport: TransportInterface,
session_id: str,
delay_seconds: float,
) -> None:
"""Finalize ASR after datagram tail frames have crossed the router."""
current_task = asyncio.current_task()
try:
if delay_seconds > 0:
await asyncio.sleep(delay_seconds)
if context.session_id != session_id:
return
if (
getattr(transport, "has_datagram_audio", False)
and not context.conversation_active
):
return
# Close the tail-grace ingress gate before resolving ASR.
# Component lookup may fail; leaving it open would leak subsequent
# packets into a turn that has already stopped.
context.listen_stop_pending = False
context.client_voice_stop = True
if getattr(transport, "requires_audio_tail_grace", False):
context.accepting_input_audio = False
asr_component = await self._get_component(
context, ComponentType.ASR
)
if context.session_id != session_id:
return
asr_instance = (
getattr(asr_component, "asr_instance", None)
if asr_component
else None
)
if not asr_instance or not hasattr(
asr_instance, "interface_type"
):
return
from core.providers.asr.dto.dto import InterfaceType
if asr_instance.interface_type == InterfaceType.STREAM:
if hasattr(asr_instance, "_send_stop_request"):
await asr_instance._send_stop_request()
return
if context.asr_audio:
asr_audio_task = context.asr_audio.copy()
context.asr_audio.clear()
context.reset_vad_states()
await asr_instance.handle_voice_stop(
context, asr_audio_task
)
except asyncio.CancelledError:
raise
except Exception as error:
logger.error(
"listen stop收尾失败: session_id={}, error={}",
session_id,
error,
)
finally:
if getattr(context, "listen_stop_task", None) is current_task:
context.listen_stop_task = None
context.listen_stop_deadline = 0.0
if context.session_id == session_id:
context.listen_stop_pending = False
if getattr(transport, "requires_audio_tail_grace", False):
context.accepting_input_audio = False
def _arm_wake_audio_suppression(self, context: SessionContext) -> None:
"""Bound the UDP/TCP reorder window used by the legacy gateway flow."""
arm_wake_audio_suppression(context)
async def _handle_device_call(
self,
context: SessionContext,
transport: TransportInterface,
call_text: str,
) -> None:
"""Restore the legacy device-call announcement and call-state handoff."""
logger.info(f"收到设备呼叫指令: {call_text}")
context.incoming_call = True
context.sentence_id = uuid.uuid4().hex
await self._send_stt_message(context, transport, call_text)
tts_component = await self._get_component(context, ComponentType.TTS)
tts_instance = (
getattr(tts_component, "tts_instance", None)
if tts_component
else None
)
if tts_instance:
tts_instance.store_tts_text(context.sentence_id, call_text)
tts_instance.tts_text_queue.put(
TTSMessageDTO(
sentence_id=context.sentence_id,
sentence_type=SentenceType.FIRST,
content_type=ContentType.ACTION,
)
)
tts_instance.tts_one_sentence(
context,
ContentType.TEXT,
content_detail=call_text,
)
tts_instance.tts_text_queue.put(
TTSMessageDTO(
sentence_id=context.sentence_id,
sentence_type=SentenceType.LAST,
content_type=ContentType.ACTION,
)
)
context.dialogue.put(Message(role="assistant", content=call_text))
async def _handle_audio_message(self, context: SessionContext, transport: TransportInterface, audio: bytes):
"""处理音频消息 - 调用AudioReceiveProcessor"""
# 这里应该调用AudioReceiveProcessor来处理音频
from core.processors.audio_receive_processor import AudioReceiveProcessor
audio_processor = AudioReceiveProcessor()
await audio_processor.handle_audio_message(context, transport, audio)
async def _send_stt_message(self, context: SessionContext, transport: TransportInterface, text: str):
"""发送STT消息"""
from core.processors.audio_send_processor import AudioSendProcessor
await AudioSendProcessor().send_stt_message(
context, transport, text
)
async def _send_tts_message(self, context: SessionContext, transport: TransportInterface, state: str, text: str = None):
"""发送TTS消息"""
message = {
"type": "tts",
"state": state,
"session_id": context.session_id
}
if text:
message["text"] = text
await transport.send(json.dumps(message))
logger.debug(f"发送TTS消息: state={state}, text={text}")
async def _get_component(self, context: SessionContext, component_type: ComponentType):
if not context.component_manager:
return None
return await context.component_manager.get_component(component_type, context)
async def _enqueue_asr_report(self, context: SessionContext, text: str, audio_data: list):
"""ASR上报队列"""
if context.report_asr_enable:
from core.processors.report_processor import ReportProcessor
report_processor = ReportProcessor()
report_processor.enqueue_asr_report(context, text, audio_data)
async def _start_to_chat(self, context: SessionContext, transport: TransportInterface, text: str, skip_intent: bool = False):
"""开始聊天 - 调用ChatProcessor"""
# 与旧架构一致:先发送STT,再异步触发聊天,避免阻塞事件循环
await self._send_stt_message(context, transport, text)
from core.processors.chat_processor import ChatProcessor
chat_processor = ChatProcessor()
if hasattr(context, "create_background_task"):
context.create_background_task(
chat_processor.handle_chat(
context, transport, text, skip_intent=skip_intent
),
turn_scoped=True,
)
logger.info(f"chat任务已提交: session_id={context.session_id}")
else:
await chat_processor.handle_chat(context, transport, text, skip_intent=skip_intent)
@@ -0,0 +1,96 @@
import json
from typing import Any
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from core.providers.tools.device_mcp import handle_mcp_message
from config.logger import setup_logging
logger = setup_logging()
class McpProcessor(MessageProcessor):
"""MCP消息处理器:完整迁移mcpMessageHandler.py的所有功能"""
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""处理mcp类型的消息"""
msg_json = None
if isinstance(message, str):
try:
msg_json = json.loads(message)
except json.JSONDecodeError:
msg_json = None
elif isinstance(message, dict):
msg_json = message
if isinstance(msg_json, dict) and msg_json.get("type") == "mcp":
await self.handle_mcp_message(context, transport, msg_json)
return True
return False
async def handle_mcp_message(self, context: SessionContext, transport: TransportInterface, msg_json: dict):
"""处理MCP消息 - 完整迁移自mcpMessageHandler.py"""
if "payload" in msg_json:
# 检查MCP客户端是否存在
if not context.mcp_client:
logger.warning("MCP客户端未初始化,无法处理MCP消息")
await self._send_error_response(transport, context.session_id, "MCP客户端未初始化")
return
# 创建异步任务处理MCP消息 - 完整迁移原逻辑
context.create_background_task(
self._handle_mcp_payload(
context, transport, msg_json["payload"]
),
conversation_scoped=False,
)
else:
logger.warning("MCP消息缺少payload字段")
await self._send_error_response(transport, context.session_id, "MCP消息格式错误:缺少payload")
async def _handle_mcp_payload(self, context: SessionContext, transport: TransportInterface, payload: dict):
"""处理MCP payload - 包装原handle_mcp_message函数"""
try:
# 调用原有的handle_mcp_message函数
# 注意:这里需要传入context而不是conn,因为handle_mcp_message可能需要适配
await handle_mcp_message(context, context.mcp_client, payload, transport)
logger.debug("MCP消息处理完成")
except Exception as e:
logger.error(f"处理MCP消息失败: {e}", exc_info=True)
await self._send_error_response(
transport,
context.session_id,
f"MCP消息处理失败: {str(e)}"
)
async def _send_error_response(self, transport: TransportInterface, session_id: str, message: str):
"""发送MCP错误响应"""
response = {
"type": "mcp",
"status": "error",
"message": message,
"session_id": session_id
}
try:
await transport.send(json.dumps(response))
except Exception as e:
logger.error(f"发送MCP错误响应失败: {e}")
async def _send_success_response(self, transport: TransportInterface, session_id: str,
message: str, data: dict = None):
"""发送MCP成功响应"""
response = {
"type": "mcp",
"status": "success",
"message": message,
"session_id": session_id
}
if data:
response["data"] = data
try:
await transport.send(json.dumps(response))
except Exception as e:
logger.error(f"发送MCP成功响应失败: {e}")
@@ -0,0 +1,124 @@
import json
from typing import Any, List
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from core.processors.hello_processor import HelloProcessor
from core.processors.listen_processor import ListenProcessor
from core.processors.audio_receive_processor import AudioReceiveProcessor
from core.processors.auth_processor import AuthProcessor
from core.processors.timeout_processor import TimeoutProcessor
from core.processors.server_processor import ServerProcessor
from core.processors.mcp_processor import McpProcessor
from core.processors.iot_processor import IotProcessor
from core.processors.abort_processor import AbortProcessor
from core.processors.goodbye_processor import GoodbyeProcessor
from core.processors.text_processor import TextProcessor
from core.processors.ping_processor import PingProcessor
from config.logger import setup_logging
logger = setup_logging()
class MessageRouter(MessageProcessor):
"""
消息路由器:协调所有独立的processor
按功能职责分离,避免耦合,每个processor专注单一职责
"""
def __init__(self):
# 初始化所有独立的processor
self.auth_processor = AuthProcessor()
self.timeout_processor = TimeoutProcessor()
self.abort_processor = AbortProcessor()
self.goodbye_processor = GoodbyeProcessor()
self.hello_processor = HelloProcessor()
self.listen_processor = ListenProcessor()
self.server_processor = ServerProcessor()
self.mcp_processor = McpProcessor()
self.iot_processor = IotProcessor()
self.audio_receive_processor = AudioReceiveProcessor()
self.text_processor = TextProcessor()
self.ping_processor = PingProcessor()
# 按优先级排序的processor列表
self.processors: List[MessageProcessor] = [
self.timeout_processor, # 首先检查超时
self.auth_processor, # 然后检查认证
self.server_processor, # 服务器消息(管理端下发动作)
self.abort_processor, # 中断消息
self.goodbye_processor, # goodbye消息
self.hello_processor, # hello消息
self.ping_processor, # 可选JSON ping/pong心跳
self.listen_processor, # listen消息
self.mcp_processor, # MCP消息
self.iot_processor, # IoT消息
self.audio_receive_processor, # 音频消息
self.text_processor, # 纯文本消息(放在最后,作为兜底处理)
]
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""
路由消息到合适的processor
每个processor专注处理自己的消息类型,避免耦合
"""
# Audio activity is updated only after VAD confirms voice. Treating
# every silent frame as activity prevents logical-session timeouts.
is_audio = isinstance(message, bytes) or (
isinstance(message, dict) and message.get("type") == "audio"
)
if not is_audio:
context.update_activity()
# 按优先级顺序尝试每个processor
for processor in self.processors:
try:
if await processor.process(context, transport, message):
# 消息已被处理,记录日志并返回
logger.debug(f"消息被 {processor.__class__.__name__} 处理")
return True
except Exception as e:
logger.error(f"{processor.__class__.__name__} 处理消息时出错: {e}", exc_info=True)
continue
# 如果没有processor处理该消息,记录警告
if isinstance(message, str):
try:
msg_json = json.loads(message)
msg_type = msg_json.get("type", "unknown") if isinstance(msg_json, dict) else "non-dict"
logger.warning(f"未处理的消息类型: {msg_type}, 内容: {message[:100]}...")
except json.JSONDecodeError:
logger.warning(f"未处理的非JSON消息: {message[:100]}...")
elif isinstance(message, bytes):
logger.warning(f"未处理的二进制消息,大小: {len(message)} bytes")
else:
logger.warning(f"未处理的消息类型: {type(message)}, {message}")
return False
def add_processor(self, processor: MessageProcessor, priority: int = None):
"""
添加新的processor
priority: 优先级,数字越小优先级越高,None表示添加到末尾
"""
if priority is None:
self.processors.append(processor)
else:
self.processors.insert(priority, processor)
logger.info(f"添加processor: {processor.__class__.__name__}")
def remove_processor(self, processor_class):
"""移除指定类型的processor"""
self.processors = [p for p in self.processors if not isinstance(p, processor_class)]
logger.info(f"移除processor: {processor_class.__name__}")
def get_processor(self, processor_class):
"""获取指定类型的processor"""
for processor in self.processors:
if isinstance(processor, processor_class):
return processor
return None
def list_processors(self) -> List[str]:
"""列出所有processor的名称"""
return [processor.__class__.__name__ for processor in self.processors]
@@ -0,0 +1,40 @@
import json
import time
from typing import Any
from core.context.session_context import SessionContext
from core.pipeline.message_pipeline import MessageProcessor
from core.transport.transport_interface import TransportInterface
from config.logger import setup_logging
logger = setup_logging()
class PingProcessor(MessageProcessor):
"""Handle the optional legacy JSON ping/pong control contract."""
async def process(
self,
context: SessionContext,
transport: TransportInterface,
message: Any,
) -> bool:
if isinstance(message, str):
try:
message = json.loads(message)
except json.JSONDecodeError:
return False
if not isinstance(message, dict) or message.get("type") != "ping":
return False
if not context.config.get("enable_websocket_ping", False):
logger.debug("WebSocket心跳功能未启用,忽略PING消息")
return True
await transport.send_json(
{
"type": "pong",
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
}
)
return True
@@ -0,0 +1,301 @@
import asyncio
import time
import queue
import threading
import json
from typing import Any, List
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from config.manage_api_client import ManageApiClient, report as manage_report
from config.logger import setup_logging
logger = setup_logging()
class ReportProcessor(MessageProcessor):
"""上报处理器:完整迁移reportHandle.py的所有功能"""
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""这个处理器不直接处理消息,而是被其他处理器调用"""
return False
def enqueue_asr_report(self, context: SessionContext, text: str, audio_data: List[bytes]):
"""ASR上报队列 - 完整迁移自enqueue_asr_report"""
if not self._should_report(context, context.report_asr_enable):
return
report_time = int(time.time() * 1000)
if context.chat_history_conf != 2:
audio_data = None
# 将上报任务放入队列
self._enqueue_report(context, {
"type": 1, # 用户类型
"text": text,
"audio_data": audio_data,
"report_time": report_time,
"session_id": context.session_id,
"device_id": context.device_id,
})
# 确保上报线程已启动
self._ensure_report_thread(context)
def enqueue_tts_report(self, context: SessionContext, text: str, opus_data: bytes):
"""TTS上报队列 - 完整迁移自enqueue_tts_report"""
if not self._should_report(context, context.report_tts_enable):
return
report_time = int(time.time() * 1000)
if context.chat_history_conf != 2:
opus_data = None
# 将上报任务放入队列
self._enqueue_report(context, {
"type": 2, # 智能体类型
"text": text,
"audio_data": opus_data,
"report_time": report_time,
"session_id": context.session_id,
"device_id": context.device_id,
})
# 确保上报线程已启动
self._ensure_report_thread(context)
def enqueue_tool_report(
self,
context: SessionContext,
tool_name: str,
tool_input: dict,
tool_result: str = None,
report_tool_call: bool = True,
):
if not self._should_report(context, True):
return
timestamp = int(time.time() * 1000)
entries = []
if report_tool_call:
entries.append(
(
json.dumps(
[{"type": "tool", "text": f"{tool_name}({json.dumps(tool_input, ensure_ascii=False)})"}],
ensure_ascii=False,
),
timestamp,
)
)
if tool_result is not None:
entries.append(
(
json.dumps(
[{"type": "tool_result", "text": json.dumps({"result": str(tool_result)}, ensure_ascii=False)}],
ensure_ascii=False,
),
timestamp + 1,
)
)
for text, report_time in entries:
self._enqueue_report(
context,
{
"type": 3,
"text": text,
"audio_data": None,
"report_time": report_time,
"session_id": context.session_id,
"device_id": context.device_id,
},
)
if entries:
self._ensure_report_thread(context)
def _ensure_report_thread(self, context: SessionContext):
"""确保上报线程已启动"""
if context.report_thread is None or not context.report_thread.is_alive():
if not getattr(context, "_report_cleanup_registered", False):
context.register_cleanup(
lambda: asyncio.to_thread(self.cleanup_session, context)
)
context._report_cleanup_registered = True
context.report_thread = threading.Thread(
target=self._report_worker,
args=(context,),
daemon=True
)
context.report_thread.start()
logger.info(f"上报线程已启动: {context.session_id}")
def _enqueue_report(self, context: SessionContext, report_task: dict) -> None:
try:
context.report_queue.put_nowait(report_task)
except queue.Full:
try:
context.report_queue.get_nowait()
context.report_queue.put_nowait(report_task)
logger.warning("上报队列已满,丢弃最旧任务: {}", context.session_id)
except (queue.Empty, queue.Full):
logger.warning("上报队列持续拥塞,丢弃当前任务: {}", context.session_id)
def _should_report(self, context: SessionContext, enabled: bool) -> bool:
return bool(
enabled
and context.read_config_from_api
and not context.need_bind
and context.chat_history_conf != 0
)
def _report_worker(self, context: SessionContext):
"""上报工作线程 - 完整迁移自ConnectionHandler中的上报逻辑"""
logger.info(f"上报工作线程启动: {context.session_id}")
event_loop = asyncio.new_event_loop()
asyncio.set_event_loop(event_loop)
try:
while not context.stop_event.is_set():
try:
# 从队列获取上报任务
report_task = context.report_queue.get(timeout=1)
if report_task is None:
break
# 执行上报
self._execute_report(context, report_task, event_loop)
except queue.Empty:
continue
except Exception as e:
logger.error(f"上报工作线程异常: {e}")
finally:
try:
event_loop.run_until_complete(
ManageApiClient.close_current_loop_client()
)
except Exception as exc:
logger.debug("关闭上报HTTP客户端失败: {}", exc)
event_loop.close()
logger.info(f"上报工作线程退出: {context.session_id}")
def _execute_report(
self, context: SessionContext, report_task: dict,
event_loop=None
):
"""执行聊天记录上报操作 - 完整迁移自report函数"""
try:
report_type = report_task["type"]
text = report_task["text"]
audio_data = report_task["audio_data"]
report_time = report_task["report_time"]
# 处理音频数据
processed_audio = None
if audio_data:
if isinstance(audio_data, list):
# ASR音频数据(多个音频片段)
processed_audio = self._process_asr_audio(audio_data)
elif isinstance(audio_data, bytes):
# TTS音频数据(opus格式)
processed_audio = self._opus_to_wav(audio_data)
# 执行上报
coroutine = manage_report(
mac_address=report_task["device_id"],
session_id=report_task["session_id"],
chat_type=report_type,
content=text,
audio=processed_audio,
report_time=report_time,
)
if event_loop is None:
asyncio.run(coroutine)
else:
event_loop.run_until_complete(coroutine)
logger.debug(f"上报成功: type={report_type}, text={text[:50]}...")
except Exception as e:
logger.error(f"聊天记录上报失败: {e}")
def _process_asr_audio(self, audio_data_list: List[bytes]) -> bytes:
"""处理ASR音频数据"""
try:
# 将多个音频片段合并
combined_audio = b''.join(audio_data_list)
return self._pcm_to_wav(combined_audio)
except Exception as e:
logger.error(f"处理ASR音频数据失败: {e}")
return b''
def _pcm_to_wav(self, pcm_data: bytes) -> bytes:
import io
import wave
wav_buffer = io.BytesIO()
with wave.open(wav_buffer, 'wb') as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(16000)
wav_file.writeframes(pcm_data)
return wav_buffer.getvalue()
def _opus_to_wav(self, opus_data: bytes) -> bytes:
"""将Opus数据转换为WAV格式的字节流 - 完整迁移自opus_to_wav"""
try:
import opuslib_next
import io
import wave
# Opus解码器配置
sample_rate = 16000
channels = 1
# 创建Opus解码器
decoder = opuslib_next.Decoder(sample_rate, channels)
# 解码Opus数据
pcm_data = decoder.decode(opus_data, frame_size=960)
# 创建WAV文件
wav_buffer = io.BytesIO()
with wave.open(wav_buffer, 'wb') as wav_file:
wav_file.setnchannels(channels)
wav_file.setsampwidth(2) # 16-bit
wav_file.setframerate(sample_rate)
wav_file.writeframes(pcm_data)
return wav_buffer.getvalue()
except Exception as e:
logger.error(f"Opus转WAV失败: {e}")
return b''
def cleanup_session(self, context: SessionContext):
"""清理会话上报资源"""
# 停止上报线程
if context.report_thread and context.report_thread.is_alive():
context.stop_event.set()
try:
context.report_queue.put_nowait(None)
except queue.Full:
try:
context.report_queue.get_nowait()
context.report_queue.put_nowait(None)
except (queue.Empty, queue.Full):
pass
context.report_thread.join(timeout=5)
if context.report_thread.is_alive():
logger.warning(f"上报线程未能按时退出: {context.session_id}")
else:
context.report_thread = None
# 清理上报队列
try:
while not context.report_queue.empty():
context.report_queue.get_nowait()
except queue.Empty:
pass
logger.info(f"上报资源清理完成: {context.session_id}")
@@ -0,0 +1,177 @@
import json
import os
import sys
import time
import threading
import subprocess
from pathlib import Path
from typing import Any
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from config.logger import setup_logging
from core.utils.restart import restart_server
logger = setup_logging()
class ServerProcessor(MessageProcessor):
"""服务器消息处理器:完整迁移serverMessageHandler.py的所有功能"""
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""处理server类型的消息"""
msg_json = None
if isinstance(message, str):
try:
msg_json = json.loads(message)
except json.JSONDecodeError:
msg_json = None
elif isinstance(message, dict):
msg_json = message
if isinstance(msg_json, dict) and msg_json.get("type") == "server":
await self.handle_server_message(context, transport, msg_json)
return True
return False
async def handle_server_message(self, context: SessionContext, transport: TransportInterface, msg_json: dict):
"""处理server消息 - 完整迁移自serverMessageHandler.py"""
# 如果配置是从API读取的,则需要验证secret
if not context.read_config_from_api:
return
# 获取post请求的secret
post_secret = msg_json.get("content", {}).get("secret", "")
secret = context.config.get("manager-api", {}).get("secret", "")
# 如果secret不匹配,则返回
if post_secret != secret:
await self._send_error_response(
transport,
context.session_id,
"服务器密钥验证失败"
)
await self._close_transport(transport)
return
# 处理不同的action
action = msg_json.get("action")
if action == "update_config":
await self._handle_update_config(context, transport, msg_json)
elif action == "restart":
await self._handle_restart(context, transport, msg_json)
else:
await self._send_error_response(
transport,
context.session_id,
f"未知的服务器操作: {action}"
)
await self._close_transport(transport)
async def _handle_update_config(self, context: SessionContext, transport: TransportInterface, msg_json: dict):
"""处理配置更新 - 完整迁移自update_config逻辑"""
try:
# 检查是否有服务器实例
if not context.server:
await self._send_error_response(
transport,
context.session_id,
"无法获取服务器实例",
{"action": "update_config"}
)
await self._close_transport(transport)
return
# 更新WebSocketServer的配置
if not await context.server.update_config():
error_msg = ""
if hasattr(context.server, "get_last_update_error"):
error_msg = context.server.get_last_update_error()
await self._send_error_response(
transport,
context.session_id,
error_msg or "更新服务器配置失败",
{"action": "update_config"}
)
await self._close_transport(transport)
return
# 发送成功响应
await self._send_success_response(
transport,
context.session_id,
"配置更新成功",
{"action": "update_config"}
)
await self._close_transport(transport)
except Exception as e:
logger.error(f"更新配置失败: {str(e)}")
await self._send_error_response(
transport,
context.session_id,
f"更新配置失败: {str(e)}",
{"action": "update_config"}
)
await self._close_transport(transport)
async def _handle_restart(self, context: SessionContext, transport: TransportInterface, msg_json: dict):
"""处理服务器重启 - 完整迁移自handle_restart逻辑"""
try:
# 发送确认响应
await self._send_success_response(
transport,
context.session_id,
"服务器重启中...",
{"action": "restart"}
)
await self._close_transport(transport)
# 异步执行重启操作
threading.Thread(target=lambda : restart_server(logger), daemon=True).start()
except Exception as e:
logger.error(f"处理重启请求失败: {str(e)}")
await self._send_error_response(
transport,
context.session_id,
f"重启失败: {str(e)}",
{"action": "restart"}
)
await self._close_transport(transport)
async def _send_success_response(self, transport: TransportInterface, session_id: str,
message: str, content: dict = None):
"""发送成功响应"""
response = {
"type": "server",
"status": "success",
"message": message,
"session_id": session_id
}
if content:
response["content"] = content
await transport.send(json.dumps(response))
logger.info(f"服务器操作成功: {message}")
async def _send_error_response(self, transport: TransportInterface, session_id: str,
message: str, content: dict = None):
"""发送错误响应"""
response = {
"type": "server",
"status": "fail",
"message": message,
"session_id": session_id
}
if content:
response["content"] = content
await transport.send(json.dumps(response))
logger.error(f"服务器操作失败: {message}")
async def _close_transport(self, transport: TransportInterface):
try:
await transport.close()
except Exception:
pass
@@ -0,0 +1,54 @@
import json
from typing import Any
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from config.logger import setup_logging
logger = setup_logging()
class TextProcessor(MessageProcessor):
"""
纯文本消息处理器:处理非JSON格式的文本消息
这是新架构中缺失的重要组件,用于处理直接发送的文本聊天内容
"""
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""处理纯文本消息"""
if isinstance(message, str):
try:
# 尝试解析为JSON,如果成功则不是纯文本消息
json.loads(message)
return False # JSON消息由其他processor处理
except json.JSONDecodeError:
# 确实是纯文本消息,进行聊天处理
await self.handle_text_message(context, transport, message)
return True
return False
async def handle_text_message(self, context: SessionContext, transport: TransportInterface, text: str):
"""处理纯文本消息 - 直接调用ChatProcessor进行聊天"""
try:
# 记录收到纯文本消息
logger.info(f"收到纯文本消息: {text[:100]}...")
# 使用ChatProcessor处理聊天
from core.processors.chat_processor import ChatProcessor
chat_processor = ChatProcessor()
await chat_processor.handle_chat(context, transport, text)
except Exception as e:
logger.error(f"处理纯文本消息失败: {e}")
# 发送错误响应
await self._send_error_response(transport, "文本处理失败,请重试")
async def _send_error_response(self, transport: TransportInterface, error_message: str):
"""发送错误响应"""
try:
await transport.send(json.dumps({
"type": "error",
"message": error_message
}))
except Exception as e:
logger.error(f"发送错误响应失败: {e}")
@@ -0,0 +1,48 @@
from typing import Any
from core.pipeline.message_pipeline import MessageProcessor
from core.context.session_context import SessionContext
from core.transport.transport_interface import TransportInterface
from config.logger import setup_logging
logger = setup_logging()
class TimeoutProcessor(MessageProcessor):
"""超时检查处理器:检查会话是否超时"""
async def process(self, context: SessionContext, transport: TransportInterface, message: Any) -> bool:
"""检查会话超时"""
return await self.handle_timeout(context, transport)
async def handle_timeout(
self,
context: SessionContext,
transport: TransportInterface,
) -> bool:
"""End an expired logical conversation without closing a long connection."""
if not getattr(context, "conversation_active", False):
return False
timeout_seconds = context.config.get("close_connection_no_voice_time", 120)
if not context.is_timeout(timeout_seconds):
return False
try:
if transport.keeps_connection_between_sessions:
logger.info(f"会话超时,结束长连接逻辑会话: {context.session_id}")
from core.processors.audio_receive_processor import (
AudioReceiveProcessor,
)
await AudioReceiveProcessor()._no_voice_close_connect(
context,
transport,
have_voice=False,
)
else:
logger.info(f"会话超时,准备关闭连接: {context.session_id}")
await transport.close()
except Exception as e:
logger.error(f"处理超时连接失败: {e}")
return True