realtime_dialog_client.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. """
  2. 豆包API实时对话客户端
  3. 基于官方demo实现
  4. """
  5. import gzip
  6. import json
  7. import uuid
  8. import asyncio
  9. from typing import Dict, Any, Optional
  10. import websockets
  11. from loguru import logger
  12. from . import protocol
  13. class RealtimeDialogClient:
  14. """豆包API实时对话客户端"""
  15. def __init__(
  16. self,
  17. api_key: str,
  18. app_id: str,
  19. base_url: str = "wss://openspeech.bytedance.com/api/v3/realtime/dialogue",
  20. output_audio_format: str = "ogg_opus", # pcm 或 ogg_opus
  21. recv_timeout: int = 15,
  22. connect_timeout: int = 10
  23. ):
  24. self.api_key = api_key
  25. self.app_id = app_id
  26. self.base_url = base_url
  27. self.output_audio_format = output_audio_format
  28. self.recv_timeout = recv_timeout
  29. self.connect_timeout = connect_timeout
  30. # 生成唯一会话ID
  31. self.session_id = f"session_{uuid.uuid4().hex[:16]}"
  32. self.ws = None
  33. self.logid = ""
  34. self.connected = False
  35. self.has_fatal_error = False # 标记是否遇到致命错误(如超时)
  36. # 配置请求头
  37. self.headers = {
  38. "X-Api-App-ID": app_id,
  39. "X-Api-Access-Key": api_key,
  40. "X-Api-Resource-Id": "volc.speech.dialog",
  41. "X-Api-App-Key": "PlgvMymc7f3tQnJ6",
  42. "X-Api-Connect-Id": str(uuid.uuid4()),
  43. }
  44. async def connect(self):
  45. """建立WebSocket连接并初始化会话"""
  46. try:
  47. logger.info(f"连接豆包API: {self.base_url}")
  48. logger.debug(f"会话ID: {self.session_id}")
  49. # 建立WebSocket连接
  50. self.ws = await asyncio.wait_for(
  51. websockets.connect(
  52. self.base_url,
  53. extra_headers=self.headers,
  54. ping_interval=None
  55. ),
  56. timeout=self.connect_timeout
  57. )
  58. # 获取logid
  59. try:
  60. if hasattr(self.ws, 'response_headers'):
  61. self.logid = self.ws.response_headers.get("X-Tt-Logid", "")
  62. else:
  63. self.logid = ""
  64. except:
  65. self.logid = ""
  66. logger.info(f"豆包API连接成功,LogID: {self.logid}")
  67. # 发送StartConnection请求
  68. await self._send_start_connection()
  69. # 发送StartSession请求
  70. await self._send_start_session()
  71. self.connected = True
  72. logger.success(f"豆包会话已建立: {self.session_id}")
  73. except Exception as e:
  74. logger.error(f"连接豆包API失败: {e}")
  75. raise
  76. async def _send_start_connection(self):
  77. """发送StartConnection请求"""
  78. start_connection_request = bytearray(protocol.generate_header())
  79. start_connection_request.extend(int(1).to_bytes(4, 'big'))
  80. payload_bytes = str.encode("{}")
  81. payload_bytes = gzip.compress(payload_bytes)
  82. start_connection_request.extend((len(payload_bytes)).to_bytes(4, 'big'))
  83. start_connection_request.extend(payload_bytes)
  84. await self.ws.send(start_connection_request)
  85. response = await self.ws.recv()
  86. result = protocol.parse_response(response)
  87. logger.debug(f"StartConnection响应: {result}")
  88. async def _send_start_session(self):
  89. """发送StartSession请求"""
  90. # 构建会话配置
  91. session_config = {
  92. "asr": {
  93. "extra": {
  94. "end_smooth_window_ms": 800, # 优化到0.8秒,加快响应速度
  95. "max_start_silence_ms": 3000, # 最大开始静音3秒
  96. "max_end_silence_ms": 800, # 最大结束静音0.8秒
  97. },
  98. },
  99. "tts": {
  100. "speaker": "zh_male_yunzhou_jupiter_bigtts", # 使用官方音色
  101. "audio_config": {
  102. "channel": 1,
  103. "format": self.output_audio_format, # pcm 或 ogg_opus
  104. "sample_rate": 24000 if self.output_audio_format == "pcm" else 24000
  105. },
  106. "extra": {
  107. "speed": 1.05, # 略微加快语速(1.0=正常,1.05=加快5%)
  108. "streaming": True, # 启用流式TTS
  109. }
  110. },
  111. "dialog": {
  112. "bot_name": "豆包",
  113. "system_role": "你是一位英语口语陪练老师,帮助用户提高英语口语水平。请用简洁自然的英语回复,每次回复不超过2-3句话。",
  114. "speaking_style": "你的说话风格友好亲切,回复简短有力,鼓励用户多说英语。",
  115. "location": {
  116. "city": "北京",
  117. },
  118. "extra": {
  119. "strict_audit": False,
  120. "recv_timeout": 120, # 超时时间120秒
  121. "input_mod": "audio", # 音频模式
  122. "stream_mode": True, # 启用流式模式
  123. "enable_llm_streaming": True, # 启用LLM流式输出
  124. }
  125. }
  126. }
  127. payload_bytes = str.encode(json.dumps(session_config))
  128. payload_bytes = gzip.compress(payload_bytes)
  129. start_session_request = bytearray(protocol.generate_header())
  130. start_session_request.extend(int(100).to_bytes(4, 'big'))
  131. start_session_request.extend((len(self.session_id)).to_bytes(4, 'big'))
  132. start_session_request.extend(str.encode(self.session_id))
  133. start_session_request.extend((len(payload_bytes)).to_bytes(4, 'big'))
  134. start_session_request.extend(payload_bytes)
  135. await self.ws.send(start_session_request)
  136. response = await self.ws.recv()
  137. result = protocol.parse_response(response)
  138. logger.debug(f"StartSession响应: {result}")
  139. def is_connected(self) -> bool:
  140. """检查连接状态"""
  141. # 如果遇到致命错误,连接不再可用
  142. if self.has_fatal_error:
  143. return False
  144. return self.connected and self.ws and self.ws.open
  145. async def send_audio(self, audio_data: bytes):
  146. """
  147. 发送音频数据到豆包API
  148. audio_data: PCM音频数据(16kHz, 16bit, 单声道)
  149. """
  150. if not self.is_connected():
  151. raise ConnectionError("未连接到豆包API")
  152. try:
  153. # 构建音频请求(使用CLIENT_AUDIO_ONLY_REQUEST类型)
  154. task_request = bytearray(
  155. protocol.generate_header(
  156. message_type=protocol.CLIENT_AUDIO_ONLY_REQUEST,
  157. serial_method=protocol.NO_SERIALIZATION
  158. )
  159. )
  160. task_request.extend(int(200).to_bytes(4, 'big')) # event: 200 (音频数据)
  161. task_request.extend((len(self.session_id)).to_bytes(4, 'big'))
  162. task_request.extend(str.encode(self.session_id))
  163. # 压缩音频数据
  164. payload_bytes = gzip.compress(audio_data)
  165. task_request.extend((len(payload_bytes)).to_bytes(4, 'big'))
  166. task_request.extend(payload_bytes)
  167. await self.ws.send(task_request)
  168. # 只记录原始大小,避免混淆
  169. logger.debug(f"发送音频到豆包: {len(audio_data)} bytes")
  170. except Exception as e:
  171. logger.error(f"发送音频失败: {e}")
  172. raise
  173. async def receive_message(self) -> Optional[Dict[str, Any]]:
  174. """
  175. 接收豆包API返回的消息
  176. 返回解析后的消息字典
  177. """
  178. if not self.is_connected():
  179. raise ConnectionError("未连接到豆包API")
  180. try:
  181. response = await asyncio.wait_for(self.ws.recv(), timeout=5.0)
  182. data = protocol.parse_response(response)
  183. return data
  184. except asyncio.TimeoutError:
  185. logger.debug("接收消息超时")
  186. return None
  187. except Exception as e:
  188. logger.error(f"接收消息失败: {e}")
  189. raise
  190. async def receive_audio(self) -> Optional[bytes]:
  191. """
  192. 接收豆包API返回的音频数据
  193. 返回值:
  194. - bytes: 音频数据
  195. - None: 无音频数据,继续接收
  196. - False: 音频流结束
  197. """
  198. try:
  199. data = await self.receive_message()
  200. if not data:
  201. return None
  202. # 检查事件类型
  203. event = data.get('event')
  204. payload_msg = data.get('payload_msg')
  205. # Event 352: TTS音频数据(豆包实际返回的事件类型)
  206. # Event 400: 其他音频数据
  207. if event in [352, 400] and isinstance(payload_msg, bytes):
  208. logger.debug(f"收到豆包音频数据 (Event {event}): {len(payload_msg)} bytes")
  209. return payload_msg
  210. # Event 350: TTS文本(AI回复的文本内容)
  211. elif event == 350 and isinstance(payload_msg, dict):
  212. tts_text = payload_msg.get('text', '')
  213. tts_type = payload_msg.get('tts_type', 'unknown')
  214. logger.info(f"📤 [TTS文本] Type: {tts_type}, Text: {tts_text}")
  215. return None
  216. # Event 351: TTS音频结束(真正的结束标志)
  217. # Event 401: 对话结束
  218. elif event in [351, 401]:
  219. logger.info(f"豆包音频流结束 (Event {event})")
  220. return False # 返回False表示结束
  221. # Event 450: ASR中间结果(用户正在说话)
  222. # Event 451: ASR识别结果(实际事件类型)
  223. elif event in [450, 451] and isinstance(payload_msg, dict):
  224. # 从results数组中提取文本
  225. asr_text = ''
  226. results = payload_msg.get('results', [])
  227. if results and len(results) > 0:
  228. asr_text = results[0].get('text', '')
  229. is_final = results[0].get('is_final', False) if results else False
  230. logger.info(f"📥 [ASR识别] {'[最终]' if is_final else '[中间]'} Text: {asr_text}")
  231. # 返回ASR文本给websocket_handler
  232. return ('asr_text', asr_text)
  233. # Event 459: ASR最终结果(用户说话结束)
  234. elif event == 459 and isinstance(payload_msg, dict):
  235. # 从results数组中提取文本
  236. asr_text = ''
  237. results = payload_msg.get('results', [])
  238. if results and len(results) > 0:
  239. asr_text = results[0].get('text', '')
  240. logger.info(f"📥 [ASR最终结果] Text: {asr_text}")
  241. return None
  242. # Event 550: LLM流式文本输出
  243. elif event == 550 and isinstance(payload_msg, dict):
  244. content = payload_msg.get('content', '')
  245. logger.debug(f"💬 [LLM文本片段] {content}")
  246. # 返回一个特殊标记,让websocket_handler知道这是LLM文本
  247. return ('llm_text', content)
  248. # Event 359: 无内容
  249. elif event == 359:
  250. logger.debug("豆包返回无内容")
  251. return False # 返回False表示结束
  252. # 其他事件(文本、状态等),继续等待
  253. else:
  254. if isinstance(payload_msg, dict):
  255. # 检查是否是错误消息
  256. if 'error' in payload_msg:
  257. error_msg = payload_msg['error']
  258. logger.error(f"❌ 豆包错误 (Event {event}): {error_msg}")
  259. # 检查是否是致命错误(如超时)
  260. if 'IdleTimeoutError' in error_msg or 'TimeoutError' in error_msg:
  261. logger.error(f"🚫 检测到致命错误,标记连接为不可用: {self.session_id}")
  262. self.has_fatal_error = True
  263. self.connected = False
  264. return False # 停止接收
  265. else:
  266. logger.debug(f"收到豆包事件 {event}: {payload_msg}")
  267. else:
  268. logger.debug(f"收到豆包事件: {event}")
  269. return None # 继续接收
  270. except Exception as e:
  271. logger.error(f"接收音频失败: {e}")
  272. return None
  273. async def finish_session(self):
  274. """结束会话"""
  275. if not self.is_connected():
  276. return
  277. try:
  278. finish_session_request = bytearray(protocol.generate_header())
  279. finish_session_request.extend(int(102).to_bytes(4, 'big'))
  280. payload_bytes = str.encode("{}")
  281. payload_bytes = gzip.compress(payload_bytes)
  282. finish_session_request.extend((len(self.session_id)).to_bytes(4, 'big'))
  283. finish_session_request.extend(str.encode(self.session_id))
  284. finish_session_request.extend((len(payload_bytes)).to_bytes(4, 'big'))
  285. finish_session_request.extend(payload_bytes)
  286. await self.ws.send(finish_session_request)
  287. logger.debug("发送FinishSession请求")
  288. except Exception as e:
  289. logger.error(f"结束会话失败: {e}")
  290. async def finish_connection(self):
  291. """结束连接"""
  292. if not self.is_connected():
  293. return
  294. try:
  295. finish_connection_request = bytearray(protocol.generate_header())
  296. finish_connection_request.extend(int(2).to_bytes(4, 'big'))
  297. payload_bytes = str.encode("{}")
  298. payload_bytes = gzip.compress(payload_bytes)
  299. finish_connection_request.extend((len(payload_bytes)).to_bytes(4, 'big'))
  300. finish_connection_request.extend(payload_bytes)
  301. await self.ws.send(finish_connection_request)
  302. response = await self.ws.recv()
  303. result = protocol.parse_response(response)
  304. logger.debug(f"FinishConnection响应: {result}")
  305. except Exception as e:
  306. logger.error(f"结束连接失败: {e}")
  307. async def close(self):
  308. """关闭连接"""
  309. try:
  310. if self.is_connected():
  311. # 先结束会话
  312. await self.finish_session()
  313. # 再结束连接
  314. await self.finish_connection()
  315. # 关闭WebSocket
  316. await self.ws.close()
  317. self.connected = False
  318. logger.info(f"豆包API连接已关闭: {self.session_id}")
  319. except Exception as e:
  320. logger.error(f"关闭连接失败: {e}")