websocket_handler.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. """
  2. WebSocket处理器 - 新版本(STT+LLM+TTS流水线,支持流式LLM)
  3. """
  4. import json
  5. import base64
  6. import asyncio
  7. from typing import Dict, Optional
  8. from fastapi import WebSocket
  9. from models import (
  10. AudioChunkMessage,
  11. AudioEndMessage,
  12. CancelMessage,
  13. TextResultMessage,
  14. EmotionMessage,
  15. AudioResponseMessage,
  16. ErrorMessage,
  17. PingMessage,
  18. PongMessage,
  19. )
  20. from ai_service import ai_service
  21. class WebSocketHandler:
  22. """WebSocket连接处理器"""
  23. def __init__(self, websocket: WebSocket):
  24. self.websocket = websocket
  25. self.audio_buffers: Dict[str, bytearray] = {} # requestId -> audio_buffer
  26. self.cancelled_requests: set = set() # 已取消的请求ID
  27. self.recognition_sessions: Dict[str, any] = {} # requestId -> Recognition对象
  28. self.recognition_results: Dict[str, str] = {} # requestId -> 识别结果文本
  29. self.recognition_sentences: Dict[str, list] = {} # requestId -> 句子列表
  30. self.recognition_failed: set = set() # STT识别失败的请求ID
  31. self.event_loop: Optional[asyncio.AbstractEventLoop] = None # 保存事件循环引用
  32. self.request_start_times: Dict[str, float] = {} # 请求开始时间
  33. async def handle_connection(self):
  34. """处理WebSocket连接"""
  35. await self.websocket.accept()
  36. print("客户端已连接")
  37. # 保存事件循环引用,供回调线程使用
  38. self.event_loop = asyncio.get_running_loop()
  39. try:
  40. while True:
  41. # 接收消息
  42. data = await self.websocket.receive_text()
  43. message_data = json.loads(data)
  44. # 处理消息
  45. await self.handle_message(message_data)
  46. except Exception as e:
  47. print(f"WebSocket错误: {e}")
  48. finally:
  49. print("客户端已断开")
  50. async def handle_message(self, message_data: dict):
  51. """处理接收到的消息"""
  52. message_type = message_data.get("type")
  53. request_id = message_data.get("requestId")
  54. # 检查是否为已取消的请求
  55. if request_id in self.cancelled_requests:
  56. return
  57. if message_type == "audio_chunk":
  58. await self.handle_audio_chunk(AudioChunkMessage(**message_data))
  59. elif message_type == "audio_end":
  60. await self.handle_audio_end(AudioEndMessage(**message_data))
  61. elif message_type == "cancel":
  62. await self.handle_cancel(CancelMessage(**message_data))
  63. elif message_type == "ping":
  64. await self.handle_ping(PingMessage(**message_data))
  65. async def handle_audio_chunk(self, message: AudioChunkMessage):
  66. """处理音频数据块 - 流式STT"""
  67. request_id = message.requestId
  68. # 检查是否取消或STT已失败
  69. if request_id in self.cancelled_requests or request_id in self.recognition_failed:
  70. return
  71. # Base64解码音频数据
  72. audio_data = base64.b64decode(message.data)
  73. chunk_size = len(audio_data)
  74. # 初始化buffer(用于备份,以防STT失败)
  75. if request_id not in self.audio_buffers:
  76. self.audio_buffers[request_id] = bytearray()
  77. import time
  78. self.request_start_times[request_id] = time.time()
  79. print(f"\n[{request_id}] 🎤 开始接收音频流 [时间: {time.strftime('%H:%M:%S.%f')[:-3]}]")
  80. print(f"[{request_id}] 音频参数: {message.sampleRate}Hz, {message.channels}ch, {message.bitDepth}bit")
  81. self.audio_buffers[request_id].extend(audio_data)
  82. total_size = len(self.audio_buffers[request_id])
  83. # 计算已录制时长(16000 sample_rate, 16bit = 2 bytes per sample, mono)
  84. duration_seconds = total_size / (16000 * 2)
  85. # 打印音频块信息
  86. print(f"[{request_id}] 📦 接收chunk#{message.chunkIndex}: "
  87. f"大小={chunk_size}字节, 累计={total_size}字节 ({duration_seconds:.2f}秒)")
  88. # 第一个chunk:启动流式STT识别
  89. if request_id not in self.recognition_sessions:
  90. print(f"[{request_id}] 🚀 首个chunk,启动STT流式识别...")
  91. success = await self.start_streaming_stt(request_id)
  92. if not success:
  93. # STT启动失败,标记为失败状态
  94. print(f"[{request_id}] ❌ STT启动失败")
  95. self.recognition_failed.add(request_id)
  96. return
  97. # 发送音频帧到STT服务
  98. recognition = self.recognition_sessions.get(request_id)
  99. if recognition:
  100. try:
  101. # 在线程池中发送音频帧
  102. import time
  103. send_start = time.time()
  104. await asyncio.to_thread(recognition.send_audio_frame, audio_data)
  105. send_duration = (time.time() - send_start) * 1000
  106. print(f"[{request_id}] ➡️ 发送至STT: {chunk_size}字节 (耗时: {send_duration:.1f}ms)")
  107. except Exception as e:
  108. # 发送失败,标记为失败状态,避免后续继续发送
  109. if request_id not in self.recognition_failed:
  110. print(f"[{request_id}] ❌ 发送音频帧错误,停止发送: {e}")
  111. self.recognition_failed.add(request_id)
  112. async def handle_audio_end(self, message: AudioEndMessage):
  113. """处理音频结束 - 使用流式LLM优化"""
  114. request_id = message.requestId
  115. import time
  116. # 记录录音结束时间
  117. audio_end_time = time.time()
  118. # 统计音频流信息
  119. total_size = len(self.audio_buffers.get(request_id, bytearray()))
  120. duration_seconds = total_size / (16000 * 2)
  121. total_chunks = message.totalChunks
  122. print(f"\n[{request_id}] 🎤 音频录制结束 [时间: {time.strftime('%H:%M:%S.%f')[:-3]}]")
  123. print(f"[{request_id}] 📊 音频流统计: 总大小={total_size}字节, 时长={duration_seconds:.2f}秒, 共{total_chunks}个chunk")
  124. # 检查是否取消
  125. if request_id in self.cancelled_requests:
  126. print(f"[{request_id}] 请求已取消")
  127. self.cancelled_requests.remove(request_id)
  128. await self.cleanup_request(request_id)
  129. return
  130. try:
  131. # 1. STT: 等待完整识别结果(保证准确性)
  132. stt_start = time.time()
  133. # 等待一小段时间,让STT处理完最后的音频帧
  134. print(f"[{request_id}] ⏳ 等待STT处理最后的音频帧...")
  135. await asyncio.sleep(0.3) # 300ms缓冲时间
  136. await self.stop_streaming_stt(request_id)
  137. # 获取STT识别结果
  138. text = self.recognition_results.get(request_id, "")
  139. if not text:
  140. print(f"[{request_id}] STT未识别到文本,使用默认")
  141. text = "Hello, how can I help you?"
  142. # 发送最终识别结果
  143. await self.send_text_result(request_id, text, True)
  144. stt_end = time.time()
  145. stt_duration = stt_end - self.request_start_times.get(request_id, stt_start)
  146. print(f"[{request_id}] STT识别结果: {text}")
  147. print(f"[{request_id}] ⏱️ STT耗时: {stt_duration:.2f}秒")
  148. if request_id in self.cancelled_requests:
  149. return
  150. # 2. LLM: 生成回复(使用非流式,保证音频连贯性)
  151. print(f"[{request_id}] 步骤 2/3: LLM 生成回复")
  152. llm_start = time.time()
  153. # 调用非流式 LLM,获取完整回复和情感
  154. response_text, emotion = await ai_service.generate_response(text)
  155. llm_end = time.time()
  156. llm_duration = llm_end - llm_start
  157. print(f"[{request_id}] ⏱️ LLM耗时: {llm_duration:.2f}秒")
  158. if request_id in self.cancelled_requests:
  159. return
  160. # 发送情感标签
  161. await self.send_emotion(request_id, emotion)
  162. # 3. TTS: 一次性生成完整音频(保证音色一致、无停顿)
  163. print(f"[{request_id}] 步骤 3/3: TTS 生成语音")
  164. tts_start = time.time()
  165. chunk_index = 0
  166. first_audio_time = None
  167. async for audio_chunk in ai_service.text_to_speech_stream(response_text, emotion):
  168. if request_id in self.cancelled_requests:
  169. return
  170. # Base64编码
  171. audio_base64 = base64.b64encode(audio_chunk).decode()
  172. # 发送音频数据
  173. await self.send_audio_response(
  174. request_id,
  175. audio_base64,
  176. chunk_index,
  177. False,
  178. )
  179. # 记录第一个音频块发送时间
  180. if chunk_index == 0:
  181. first_audio_time = time.time()
  182. time_to_first_audio = first_audio_time - audio_end_time
  183. print(f"[{request_id}] 🎵 第一个音频块已发送 [耗时: {time_to_first_audio:.2f}秒 = 录音结束→第一个音频块]")
  184. chunk_index += 1
  185. tts_end = time.time()
  186. tts_duration = tts_end - tts_start
  187. print(f"[{request_id}] ⏱️ TTS耗时: {tts_duration:.2f}秒 (共{chunk_index}个数据块)")
  188. # 发送TTS结束标记
  189. await self.send_audio_response(request_id, "", chunk_index, True)
  190. # 记录最后一个音频块发送时间
  191. last_audio_time = time.time()
  192. time_to_last_audio = last_audio_time - audio_end_time
  193. print(f"[{request_id}] 🎵 最后一个音频块已发送 [耗时: {time_to_last_audio:.2f}秒 = 录音结束→最后一个音频块]")
  194. # 计算总耗时
  195. total_end = time.time()
  196. total_duration = total_end - self.request_start_times.get(request_id, stt_start)
  197. # 性能汇总日志
  198. print(f"\n{'='*60}")
  199. print(f"[{request_id}] 📊 性能汇总报告")
  200. print(f"{'='*60}")
  201. print(f" STT 耗时: {stt_duration:.2f}秒")
  202. print(f" LLM 耗时: {llm_duration:.2f}秒")
  203. print(f" TTS 耗时: {tts_duration:.2f}秒")
  204. if first_audio_time:
  205. print(f" 🎯 录音结束→首个音频: {time_to_first_audio:.2f}秒 ⭐")
  206. print(f" 🎯 录音结束→全部音频: {time_to_last_audio:.2f}秒")
  207. print(f" 总耗时: {total_duration:.2f}秒")
  208. print(f"{'='*60}")
  209. print(f"[{request_id}] ✅ 请求处理完成\n")
  210. except Exception as e:
  211. print(f"[{request_id}] 处理错误: {e}")
  212. import traceback
  213. traceback.print_exc()
  214. await self.send_error(request_id, "NETWORK_ERROR", str(e))
  215. finally:
  216. # 清理资源
  217. await self.cleanup_request(request_id)
  218. async def start_streaming_stt(self, request_id: str) -> bool:
  219. """启动流式STT识别,返回是否成功"""
  220. try:
  221. import time
  222. stt_init_start = time.time()
  223. print(f"[{request_id}] 🔊 启动流式STT识别...")
  224. # 发送"正在听..."的中间状态
  225. await self.send_text_result(request_id, "Listening...", False)
  226. from dashscope.audio.asr import Recognition, RecognitionCallback, RecognitionResult
  227. # 定义回调处理类
  228. handler = self
  229. req_id = request_id
  230. class StreamCallback(RecognitionCallback):
  231. def on_open(self):
  232. print(f"[{req_id}] 🔗 STT连接已打开")
  233. def on_close(self):
  234. print(f"[{req_id}] 🔌 STT连接已关闭")
  235. # 标记为失败,停止后续音频帧发送
  236. handler.recognition_failed.add(req_id)
  237. def on_error(self, error):
  238. print(f"[{req_id}] ❌ STT错误: {error}")
  239. # 标记为失败
  240. handler.recognition_failed.add(req_id)
  241. def on_event(self, result: RecognitionResult):
  242. # 获取识别的句子
  243. sentence = result.get_sentence()
  244. if sentence and 'text' in sentence:
  245. text = sentence['text']
  246. begin_time = sentence.get('begin_time', 0)
  247. end_time = sentence.get('end_time')
  248. is_final = end_time is not None and end_time > 0
  249. status = "✅最终" if is_final else "⏳中间"
  250. print(f"[{req_id}] 💬 STT识别({status}): {text} [时间: {begin_time}-{end_time}]")
  251. # 初始化句子列表
  252. if req_id not in handler.recognition_sentences:
  253. handler.recognition_sentences[req_id] = []
  254. sentence_list = handler.recognition_sentences[req_id]
  255. # 查找是否是已有句子的更新(根据begin_time匹配)
  256. updated = False
  257. for i, sent in enumerate(sentence_list):
  258. if abs(sent['begin_time'] - begin_time) < 100: # 100ms容差
  259. # 更新现有句子
  260. sentence_list[i] = {'text': text, 'begin_time': begin_time, 'end_time': end_time}
  261. updated = True
  262. break
  263. if not updated:
  264. # 新句子,追加
  265. sentence_list.append({'text': text, 'begin_time': begin_time, 'end_time': end_time})
  266. # 按时间排序并合并所有句子
  267. sentence_list.sort(key=lambda x: x['begin_time'])
  268. accumulated_text = ' '.join([s['text'] for s in sentence_list])
  269. handler.recognition_results[req_id] = accumulated_text
  270. print(f"[{req_id}] 📝 累积文本: {accumulated_text}")
  271. # 从回调线程安全地发送到事件循环
  272. if handler.event_loop:
  273. try:
  274. asyncio.run_coroutine_threadsafe(
  275. handler.send_text_result(req_id, accumulated_text, False),
  276. handler.event_loop
  277. )
  278. except Exception as e:
  279. print(f"[{req_id}] ❌ 发送中间结果失败: {e}")
  280. # 创建识别对象
  281. def create_recognition():
  282. return Recognition(
  283. model='paraformer-realtime-v2',
  284. format='pcm',
  285. sample_rate=16000,
  286. callback=StreamCallback()
  287. )
  288. recognition = await asyncio.to_thread(create_recognition)
  289. # 启动识别
  290. await asyncio.to_thread(recognition.start)
  291. # 保存识别会话
  292. self.recognition_sessions[request_id] = recognition
  293. stt_init_duration = (time.time() - stt_init_start) * 1000
  294. print(f"[{request_id}] ✅ STT识别会话已启动 (耗时: {stt_init_duration:.1f}ms)")
  295. # 短暂延迟,确保连接稳定
  296. await asyncio.sleep(0.1)
  297. # 检查是否已经失败
  298. if request_id in self.recognition_failed:
  299. print(f"[{request_id}] ❌ STT连接启动后立即失败")
  300. return False
  301. return True
  302. except Exception as e:
  303. print(f"[{request_id}] 启动STT识别失败: {e}")
  304. import traceback
  305. traceback.print_exc()
  306. return False
  307. async def stop_streaming_stt(self, request_id: str):
  308. """停止流式STT识别"""
  309. try:
  310. recognition = self.recognition_sessions.get(request_id)
  311. if recognition:
  312. print(f"[{request_id}] 🛑 停止STT识别...")
  313. import time
  314. stop_start = time.time()
  315. await asyncio.to_thread(recognition.stop)
  316. # 停止后再等待一小段时间,确保最后的识别结果回调被触发
  317. await asyncio.sleep(0.2) # 200ms等待最终结果
  318. stop_duration = (time.time() - stop_start) * 1000
  319. print(f"[{request_id}] ✅ STT识别已停止 (耗时: {stop_duration:.1f}ms)")
  320. except Exception as e:
  321. print(f"[{request_id}] ❌ 停止STT识别错误: {e}")
  322. async def cleanup_request(self, request_id: str):
  323. """清理请求相关资源"""
  324. # 清理audio buffer
  325. if request_id in self.audio_buffers:
  326. del self.audio_buffers[request_id]
  327. # 清理识别会话
  328. if request_id in self.recognition_sessions:
  329. del self.recognition_sessions[request_id]
  330. # 清理识别结果
  331. if request_id in self.recognition_results:
  332. del self.recognition_results[request_id]
  333. # 清理句子列表
  334. if request_id in self.recognition_sentences:
  335. del self.recognition_sentences[request_id]
  336. # 清理取消标记
  337. if request_id in self.cancelled_requests:
  338. self.cancelled_requests.remove(request_id)
  339. # 清理失败标记
  340. if request_id in self.recognition_failed:
  341. self.recognition_failed.remove(request_id)
  342. # 清理时间记录
  343. if request_id in self.request_start_times:
  344. del self.request_start_times[request_id]
  345. print(f"[{request_id}] 资源已清理")
  346. async def handle_cancel(self, message: CancelMessage):
  347. """处理取消请求"""
  348. request_id = message.requestId
  349. print(f"[{request_id}] 收到取消请求")
  350. self.cancelled_requests.add(request_id)
  351. # 停止STT识别
  352. await self.stop_streaming_stt(request_id)
  353. # 清理资源
  354. await self.cleanup_request(request_id)
  355. async def handle_ping(self, message: PingMessage):
  356. """处理Ping"""
  357. await self.send_pong(message.requestId)
  358. async def send_text_result(self, request_id: str, text: str, is_final: bool):
  359. """发送文本结果"""
  360. message = TextResultMessage(
  361. type="text_result",
  362. requestId=request_id,
  363. timestamp=self._get_timestamp(),
  364. text=text,
  365. isFinal=is_final,
  366. )
  367. await self.websocket.send_text(message.model_dump_json())
  368. async def send_emotion(self, request_id: str, emotion: str):
  369. """发送情感标签"""
  370. message = EmotionMessage(
  371. type="emotion",
  372. requestId=request_id,
  373. timestamp=self._get_timestamp(),
  374. emotion=emotion,
  375. )
  376. await self.websocket.send_text(message.model_dump_json())
  377. async def send_audio_response(
  378. self, request_id: str, data: str, chunk_index: int, is_last: bool,
  379. format: str = "pcm", sample_rate: int = 24000
  380. ):
  381. """发送音频响应"""
  382. message = AudioResponseMessage(
  383. type="audio_response",
  384. requestId=request_id,
  385. timestamp=self._get_timestamp(),
  386. data=data,
  387. sampleRate=sample_rate,
  388. format=format,
  389. chunkIndex=chunk_index,
  390. isLast=is_last,
  391. )
  392. await self.websocket.send_text(message.model_dump_json())
  393. async def send_error(self, request_id: str, code: str, error_message: str):
  394. """发送错误消息"""
  395. message = ErrorMessage(
  396. type="error",
  397. requestId=request_id,
  398. timestamp=self._get_timestamp(),
  399. code=code,
  400. message=error_message,
  401. )
  402. await self.websocket.send_text(message.model_dump_json())
  403. async def send_pong(self, request_id: str):
  404. """发送Pong"""
  405. message = PongMessage(
  406. type="pong",
  407. requestId=request_id,
  408. timestamp=self._get_timestamp(),
  409. )
  410. await self.websocket.send_text(message.model_dump_json())
  411. def _get_timestamp(self) -> int:
  412. """获取当前时间戳(毫秒)"""
  413. import time
  414. return int(time.time() * 1000)