realtime_dialog_client.py 14 KB

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