websocket_handler.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
  1. """
  2. WebSocket处理器模块
  3. """
  4. import json
  5. import asyncio
  6. import time
  7. from typing import Dict, Optional
  8. from fastapi import WebSocket, WebSocketDisconnect
  9. from loguru import logger
  10. from asyncio import Queue
  11. from config import settings
  12. from connection_pool import connection_pool
  13. from error_codes import ErrorCode, get_error_message
  14. from performance import performance_monitor
  15. class StreamingSession:
  16. """流式会话处理"""
  17. def __init__(self, websocket: WebSocket, session_id: str):
  18. self.websocket = websocket
  19. self.session_id = session_id
  20. self.doubao_client = None
  21. self.is_streaming = False
  22. self.is_recording = False
  23. # 音频数据队列
  24. self.to_doubao_queue: Queue = Queue()
  25. self.to_client_queue: Queue = Queue()
  26. # 性能指标
  27. self.start_time = 0
  28. self.audio_received_bytes = 0
  29. self.audio_sent_bytes = 0
  30. # 文本收集(用于日志)
  31. self.llm_text_chunks = [] # 收集LLM流式文本
  32. self.asr_text = '' # 最后一次ASR识别结果
  33. # 对话轮次控制
  34. self.response_ended = False # 标记当前轮次是否已结束(避免重复发送结束消息)
  35. # 心跳检测
  36. self.last_ping_time = time.time()
  37. self.heartbeat_timeout = 60 # 60秒无心跳则断开
  38. async def start(self):
  39. """启动会话"""
  40. try:
  41. # 从连接池获取豆包连接
  42. self.doubao_client = await connection_pool.acquire(timeout=5)
  43. if not self.doubao_client:
  44. await self.send_error(
  45. ErrorCode.CONNECTION_POOL_EXHAUSTED,
  46. "连接池已耗尽"
  47. )
  48. return False
  49. logger.info(f"会话 {self.session_id} 已启动")
  50. return True
  51. except Exception as e:
  52. logger.error(f"启动会话失败: {e}")
  53. await self.send_error(ErrorCode.INTERNAL_ERROR, f"启动会话失败: {str(e)}")
  54. return False
  55. async def process_stream(self):
  56. """处理流式数据"""
  57. try:
  58. self.is_streaming = True
  59. # 并发处理多个任务
  60. await asyncio.gather(
  61. self.receive_from_client(),
  62. self.send_to_doubao(),
  63. self.receive_from_doubao(),
  64. self.send_to_client(),
  65. self.check_heartbeat()
  66. )
  67. except Exception as e:
  68. logger.error(f"流式处理错误: {e}")
  69. await self.send_error(ErrorCode.INTERNAL_ERROR, f"流式处理错误: {str(e)}")
  70. finally:
  71. self.is_streaming = False
  72. async def receive_from_client(self):
  73. """接收客户端数据"""
  74. try:
  75. while self.is_streaming:
  76. message = await self.websocket.receive()
  77. if 'text' in message:
  78. # 控制消息
  79. await self.handle_control_message(message['text'])
  80. elif 'bytes' in message:
  81. # 音频数据
  82. if self.is_recording:
  83. audio_data = message['bytes']
  84. chunk_size = len(audio_data)
  85. self.audio_received_bytes += chunk_size
  86. performance_monitor.record_audio_received(chunk_size)
  87. # 只在DEBUG模式下记录每个音频块
  88. logger.debug(f"收到音频块: {chunk_size} bytes, 累计: {self.audio_received_bytes} bytes")
  89. await self.to_doubao_queue.put(audio_data)
  90. except WebSocketDisconnect:
  91. logger.info(f"客户端 {self.session_id} 断开连接")
  92. self.is_streaming = False
  93. except Exception as e:
  94. logger.error(f"接收客户端数据错误: {e}")
  95. self.is_streaming = False
  96. async def send_to_doubao(self):
  97. """转发音频到豆包API"""
  98. try:
  99. while self.is_streaming:
  100. audio_chunk = await self.to_doubao_queue.get()
  101. if self.doubao_client:
  102. # 转发给豆包API
  103. chunk_size = len(audio_chunk)
  104. await self.doubao_client.send_audio(audio_chunk)
  105. # 使用实例变量中的累计值(在handle_start_recording中重置)
  106. logger.debug(f"转发音频到豆包: {chunk_size} bytes")
  107. except Exception as e:
  108. logger.error(f"转发豆包错误: {e}")
  109. async def receive_from_doubao(self):
  110. """接收豆包API响应"""
  111. try:
  112. # 持续接收豆包响应(支持多轮对话)
  113. while self.is_streaming:
  114. # 持续接收,不等待录音状态
  115. # 接收当前这一轮的音频
  116. if not self.doubao_client:
  117. await asyncio.sleep(0.1)
  118. continue
  119. try:
  120. # 接收豆包音频数据,设置响应超时
  121. audio_data = await asyncio.wait_for(
  122. self.doubao_client.receive_audio(),
  123. timeout=settings.DOUBAO_RESPONSE_TIMEOUT
  124. )
  125. # 检查返回值类型
  126. if audio_data is False:
  127. # False表示当前轮次音频结束
  128. # 检查是否已经发送过结束消息(避免重复)
  129. if self.response_ended:
  130. logger.debug("已发送过结束消息,忽略重复的结束事件")
  131. continue
  132. logger.info("当前轮次音频结束,等待下一轮")
  133. # 如果发送过音频,立即发送结束消息
  134. if self.audio_sent_bytes > 0:
  135. # 计算本轮对话的延迟
  136. response_latency = int((time.time() - self.start_time) * 1000) if self.start_time > 0 else 0
  137. # 打印完整的LLM回复文本
  138. full_llm_text = ''.join(self.llm_text_chunks)
  139. logger.info(f"🔊 [AI回复完成] 音频: {self.audio_sent_bytes / 1024:.1f}KB, 延迟: {response_latency}ms")
  140. if full_llm_text:
  141. logger.info(f"💬 [完整回复] {full_llm_text}")
  142. await self.send_control_message({
  143. 'type': 'ai_response_end',
  144. 'payload': {
  145. 'responseId': f'resp_{int(self.start_time)}',
  146. 'audioSize': self.audio_sent_bytes,
  147. 'message': 'AI回复完成'
  148. }
  149. })
  150. await self.send_control_message({
  151. 'type': 'state_change',
  152. 'payload': {
  153. 'state': 'idle',
  154. 'previousState': 'speaking',
  155. 'reason': 'ai_response_completed'
  156. }
  157. })
  158. else:
  159. # 没有发送过音频,说明豆包没有回复
  160. logger.warning("⚠️ 豆包没有返回音频,可能网络问题或API异常")
  161. await self.send_control_message({
  162. 'type': 'ai_response_end',
  163. 'payload': {
  164. 'responseId': f'resp_{int(self.start_time)}',
  165. 'audioSize': 0,
  166. 'message': 'AI无回复'
  167. }
  168. })
  169. await self.send_control_message({
  170. 'type': 'state_change',
  171. 'payload': {
  172. 'state': 'idle',
  173. 'previousState': 'processing',
  174. 'reason': 'ai_no_response'
  175. }
  176. })
  177. # 标记已结束,避免重复发送
  178. self.response_ended = True
  179. # 重置音频计数器,为下一轮做准备
  180. self.audio_sent_bytes = 0
  181. # 不break,继续外层循环等待下一次录音
  182. continue
  183. elif isinstance(audio_data, tuple):
  184. # 处理文本数据
  185. if audio_data[0] == 'llm_text':
  186. # LLM文本片段
  187. self.llm_text_chunks.append(audio_data[1])
  188. elif audio_data[0] == 'asr_text':
  189. # ASR识别文本(保存最新的)
  190. self.asr_text = audio_data[1]
  191. continue
  192. elif audio_data:
  193. # bytes表示音频数据
  194. # 发送AI响应开始消息(仅第一次)
  195. if self.audio_sent_bytes == 0:
  196. await self.send_control_message({
  197. 'type': 'ai_response_start',
  198. 'payload': {
  199. 'responseId': f'resp_{int(time.time())}',
  200. 'message': 'AI开始回复'
  201. }
  202. })
  203. # 发送状态变更为speaking
  204. await self.send_control_message({
  205. 'type': 'state_change',
  206. 'payload': {
  207. 'state': 'speaking',
  208. 'previousState': 'processing',
  209. 'reason': 'ai_started_response'
  210. }
  211. })
  212. # 将音频数据放入队列
  213. await self.to_client_queue.put(audio_data)
  214. self.audio_sent_bytes += len(audio_data)
  215. performance_monitor.record_audio_sent(len(audio_data))
  216. logger.debug(f"收到豆包音频: {len(audio_data)} bytes, 总计: {self.audio_sent_bytes} bytes")
  217. # 监控输出是否过长(超过100KB警告)
  218. if self.audio_sent_bytes > 100 * 1024:
  219. logger.warning(f"⚠️ AI回复过长: {self.audio_sent_bytes / 1024:.1f}KB,可能需要优化prompt")
  220. else:
  221. # None表示其他事件(文本、状态等),继续等待
  222. logger.debug("收到豆包事件,继续等待音频")
  223. await asyncio.sleep(0.05)
  224. except asyncio.TimeoutError:
  225. logger.warning(f"接收豆包音频超时({settings.DOUBAO_RESPONSE_TIMEOUT}秒),发送超时结束消息")
  226. # 超时后发送结束消息
  227. if self.audio_sent_bytes > 0:
  228. logger.info(f"超时但有音频,已发送: {self.audio_sent_bytes} bytes")
  229. await self.send_control_message({
  230. 'type': 'ai_response_end',
  231. 'payload': {
  232. 'responseId': f'resp_{int(self.start_time)}',
  233. 'audioSize': self.audio_sent_bytes,
  234. 'message': 'AI回复完成(超时)'
  235. }
  236. })
  237. else:
  238. logger.warning("超时且无音频,豆包无回复")
  239. await self.send_control_message({
  240. 'type': 'ai_response_end',
  241. 'payload': {
  242. 'responseId': f'resp_{int(self.start_time)}',
  243. 'audioSize': 0,
  244. 'message': 'AI无回复(超时)'
  245. }
  246. })
  247. await self.send_control_message({
  248. 'type': 'state_change',
  249. 'payload': {
  250. 'state': 'idle',
  251. 'previousState': 'processing',
  252. 'reason': 'ai_timeout'
  253. }
  254. })
  255. # 重置音频计数器
  256. self.audio_sent_bytes = 0
  257. continue
  258. except Exception as e:
  259. logger.error(f"接收豆包响应错误: {e}")
  260. async def send_to_client(self):
  261. """发送音频到客户端"""
  262. try:
  263. while self.is_streaming:
  264. audio_chunk = await self.to_client_queue.get()
  265. # 发送音频数据
  266. await self.websocket.send_bytes(audio_chunk)
  267. logger.debug(f"发送音频到客户端: {len(audio_chunk)} bytes")
  268. except Exception as e:
  269. logger.error(f"发送客户端数据错误: {e}")
  270. async def check_heartbeat(self):
  271. """检查心跳超时"""
  272. try:
  273. while self.is_streaming:
  274. await asyncio.sleep(10) # 每10秒检查一次
  275. elapsed = time.time() - self.last_ping_time
  276. if elapsed > self.heartbeat_timeout:
  277. logger.warning(f"会话 {self.session_id} 心跳超时 ({elapsed:.1f}s)")
  278. await self.send_error(ErrorCode.SESSION_EXPIRED, "心跳超时,连接将关闭")
  279. self.is_streaming = False
  280. break
  281. except Exception as e:
  282. logger.error(f"心跳检测错误: {e}")
  283. async def handle_control_message(self, message_text: str):
  284. """处理控制消息"""
  285. try:
  286. message = json.loads(message_text)
  287. msg_type = message.get('type')
  288. logger.info(f"收到控制消息: {msg_type}")
  289. if msg_type == 'init':
  290. await self.handle_init(message)
  291. elif msg_type == 'start_recording':
  292. await self.handle_start_recording(message)
  293. elif msg_type == 'stop_recording':
  294. await self.handle_stop_recording(message)
  295. elif msg_type == 'ping':
  296. # 更新心跳时间
  297. self.last_ping_time = time.time()
  298. await self.send_control_message({
  299. 'type': 'pong',
  300. 'timestamp': int(time.time() * 1000)
  301. })
  302. elif msg_type == 'close':
  303. self.is_streaming = False
  304. else:
  305. logger.warning(f"未知消息类型: {msg_type}")
  306. except json.JSONDecodeError as e:
  307. logger.error(f"JSON解析错误: {e}")
  308. await self.send_error(ErrorCode.INVALID_MESSAGE)
  309. except Exception as e:
  310. logger.error(f"处理控制消息错误: {e}")
  311. await self.send_error(ErrorCode.INTERNAL_ERROR, f"处理消息失败: {str(e)}")
  312. async def handle_init(self, message: dict):
  313. """处理初始化消息"""
  314. await self.send_control_message({
  315. 'type': 'init_ack',
  316. 'id': message.get('id'),
  317. 'payload': {
  318. 'status': 'success',
  319. 'sessionId': self.session_id,
  320. 'config': {
  321. 'maxAudioChunkSize': 8192,
  322. 'heartbeatInterval': 30000
  323. }
  324. }
  325. })
  326. async def handle_start_recording(self, message: dict):
  327. """处理开始录音"""
  328. self.is_recording = True
  329. self.start_time = time.time()
  330. # 重置音频计数器(新一轮对话)
  331. self.audio_received_bytes = 0
  332. self.audio_sent_bytes = 0
  333. self.llm_text_chunks = [] # 重置文本收集
  334. self.asr_text = '' # 重置ASR文本
  335. self.response_ended = False # 重置结束标志
  336. logger.info(f"🎙️ [开始录音] 新一轮对话")
  337. await self.send_control_message({
  338. 'type': 'recording_started',
  339. 'id': message.get('id'),
  340. 'payload': {
  341. 'status': 'ready',
  342. 'message': '准备接收音频数据'
  343. }
  344. })
  345. await self.send_control_message({
  346. 'type': 'state_change',
  347. 'payload': {
  348. 'state': 'listening',
  349. 'previousState': 'idle',
  350. 'reason': 'user_started_recording'
  351. }
  352. })
  353. async def handle_stop_recording(self, message: dict):
  354. """处理停止录音"""
  355. self.is_recording = False
  356. duration = int((time.time() - self.start_time) * 1000) if self.start_time > 0 else 0
  357. # 记录录音完成信息
  358. logger.info(f"🎤 [录音完成] 时长: {duration}ms, 音频: {self.audio_received_bytes / 1024:.1f}KB")
  359. # 等待一下让ASR完成识别
  360. await asyncio.sleep(0.3)
  361. # 打印ASR识别的完整文本
  362. if self.asr_text:
  363. logger.info(f"📝 [用户说] {self.asr_text}")
  364. # 等待队列中的音频数据全部发送完成
  365. await asyncio.sleep(0.15)
  366. # 发送静音帧让豆包VAD检测到结束
  367. # 豆包配置了 end_smooth_window_ms: 1200,需要1.2秒静音
  368. # 发送1300ms静音确保触发(16kHz 16bit = 32000 bytes/s)
  369. if self.doubao_client:
  370. try:
  371. silence_duration_ms = 1300
  372. silence_bytes = int(16000 * 2 * silence_duration_ms / 1000)
  373. silence_data = b'\x00' * silence_bytes
  374. await self.doubao_client.send_audio(silence_data)
  375. logger.info(f"发送{silence_duration_ms}ms静音数据,触发VAD结束检测")
  376. except Exception as e:
  377. logger.error(f"发送静音数据失败: {e}")
  378. await self.send_control_message({
  379. 'type': 'recording_stopped',
  380. 'id': message.get('id'),
  381. 'payload': {
  382. 'status': 'success',
  383. 'duration': duration,
  384. 'message': '录音已停止,等待AI回复'
  385. }
  386. })
  387. # 注意:AI响应开始消息会在receive_from_doubao中自动发送
  388. # 这里只发送状态变更
  389. await self.send_control_message({
  390. 'type': 'state_change',
  391. 'payload': {
  392. 'state': 'processing',
  393. 'previousState': 'listening',
  394. 'reason': 'waiting_ai_response'
  395. }
  396. })
  397. async def send_control_message(self, message: dict):
  398. """发送控制消息"""
  399. try:
  400. if 'timestamp' not in message:
  401. message['timestamp'] = int(time.time() * 1000)
  402. await self.websocket.send_text(json.dumps(message))
  403. except Exception as e:
  404. logger.error(f"发送控制消息失败: {e}")
  405. async def send_error(self, error_code: int, custom_message: str = None):
  406. """发送错误消息"""
  407. message = custom_message or get_error_message(error_code)
  408. await self.send_control_message({
  409. 'type': 'error',
  410. 'payload': {
  411. 'code': error_code,
  412. 'message': message,
  413. 'severity': 'error',
  414. 'recoverable': True
  415. }
  416. })
  417. async def send_metrics(self):
  418. """发送性能指标"""
  419. latency = int((time.time() - self.start_time) * 1000) if self.start_time > 0 else 0
  420. await self.send_control_message({
  421. 'type': 'metrics',
  422. 'payload': {
  423. 'latency': {
  424. 'total': latency,
  425. 'network': 0, # TODO: 实际计算
  426. 'processing': 0, # TODO: 实际计算
  427. 'ai': 0 # TODO: 实际计算
  428. },
  429. 'audio': {
  430. 'received': self.audio_received_bytes,
  431. 'sent': self.audio_sent_bytes
  432. }
  433. }
  434. })
  435. async def cleanup(self):
  436. """清理会话"""
  437. try:
  438. # 记录会话
  439. performance_monitor.record_session()
  440. # 记录延迟
  441. if self.start_time > 0:
  442. latency = (time.time() - self.start_time) * 1000
  443. performance_monitor.record_latency(latency)
  444. # 注意:ai_response_end已在receive_from_doubao中发送,这里不重复发送
  445. # 归还连接到池中
  446. if self.doubao_client:
  447. await connection_pool.release(self.doubao_client)
  448. self.doubao_client = None
  449. logger.info(f"会话 {self.session_id} 已清理 - 收到: {self.audio_received_bytes}B, 发送: {self.audio_sent_bytes}B")
  450. except Exception as e:
  451. logger.error(f"清理会话失败: {e}")
  452. class ConnectionManager:
  453. """连接管理器"""
  454. def __init__(self):
  455. self.active_connections: Dict[str, StreamingSession] = {}
  456. async def connect(self, websocket: WebSocket, session_id: str) -> StreamingSession:
  457. """建立连接"""
  458. await websocket.accept()
  459. session = StreamingSession(websocket, session_id)
  460. self.active_connections[session_id] = session
  461. logger.info(f"新连接建立: {session_id}, 当前活跃连接数: {len(self.active_connections)}")
  462. return session
  463. async def disconnect(self, session_id: str):
  464. """断开连接"""
  465. if session_id in self.active_connections:
  466. session = self.active_connections[session_id]
  467. await session.cleanup()
  468. del self.active_connections[session_id]
  469. logger.info(f"连接已断开: {session_id}, 当前活跃连接数: {len(self.active_connections)}")
  470. def get_session(self, session_id: str) -> Optional[StreamingSession]:
  471. """获取会话"""
  472. return self.active_connections.get(session_id)
  473. # 创建全局连接管理器
  474. connection_manager = ConnectionManager()