asr_client.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. """
  2. 火山引擎大模型流式语音识别 (ASR) 客户端
  3. 协议地址: wss://openspeech.bytedance.com/api/v3/sauc/bigmodel
  4. 文档参考: https://www.volcengine.com/docs/6561/1354869
  5. """
  6. import asyncio
  7. import gzip
  8. import json
  9. import uuid
  10. import subprocess
  11. import tempfile
  12. import os
  13. import websockets
  14. from ..config.config import Config
  15. # ============ 协议常量 ============
  16. PROTOCOL_VERSION = 0b0001
  17. DEFAULT_HEADER_SIZE = 0b0001
  18. # Message Type
  19. FULL_CLIENT_REQUEST = 0b0001
  20. AUDIO_ONLY_REQUEST = 0b0010
  21. FULL_SERVER_RESPONSE = 0b1001
  22. SERVER_ACK = 0b1011
  23. SERVER_ERROR_RESPONSE = 0b1111
  24. # Message Type Specific Flags
  25. NO_SEQUENCE = 0b0000
  26. POS_SEQUENCE = 0b0001
  27. NEG_SEQUENCE = 0b0010
  28. NEG_WITH_SEQUENCE = 0b0011
  29. # Message Serialization
  30. NO_SERIALIZATION = 0b0000
  31. JSON_SERIALIZATION = 0b0001
  32. # Message Compression
  33. NO_COMPRESSION = 0b0000
  34. GZIP_COMPRESSION = 0b0001
  35. # ASR 服务地址
  36. ASR_WS_URL = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel"
  37. def _generate_header(
  38. message_type=FULL_CLIENT_REQUEST,
  39. message_type_specific_flags=NO_SEQUENCE,
  40. serial_method=JSON_SERIALIZATION,
  41. compression_type=GZIP_COMPRESSION,
  42. reserved_data=0x00,
  43. ) -> bytearray:
  44. """
  45. 生成协议头 (4 bytes):
  46. - protocol_version (4 bits) + header_size (4 bits)
  47. - message_type (4 bits) + message_type_specific_flags (4 bits)
  48. - serialization_method (4 bits) + message_compression (4 bits)
  49. - reserved (8 bits)
  50. """
  51. header = bytearray()
  52. header_size = 1
  53. header.append((PROTOCOL_VERSION << 4) | header_size)
  54. header.append((message_type << 4) | message_type_specific_flags)
  55. header.append((serial_method << 4) | compression_type)
  56. header.append(reserved_data)
  57. return header
  58. def _generate_before_payload(sequence: int) -> bytearray:
  59. """添加序列号信息 (4 bytes, big-endian, signed)"""
  60. before_payload = bytearray()
  61. before_payload.extend(sequence.to_bytes(4, 'big', signed=True))
  62. return before_payload
  63. def _parse_response(res: bytes) -> dict:
  64. """解析服务器响应帧"""
  65. protocol_version = res[0] >> 4
  66. header_size = res[0] & 0x0f
  67. message_type = res[1] >> 4
  68. message_type_specific_flags = res[1] & 0x0f
  69. serialization_method = res[2] >> 4
  70. message_compression = res[2] & 0x0f
  71. reserved = res[3]
  72. header_extensions = res[4:header_size * 4]
  73. payload = res[header_size * 4:]
  74. result = {
  75. 'is_last_package': False,
  76. 'message_type': message_type,
  77. }
  78. payload_msg = None
  79. payload_size = 0
  80. if message_type_specific_flags & 0x01:
  81. seq = int.from_bytes(payload[:4], "big", signed=True)
  82. result['payload_sequence'] = seq
  83. payload = payload[4:]
  84. if message_type_specific_flags & 0x02:
  85. result['is_last_package'] = True
  86. if message_type == FULL_SERVER_RESPONSE:
  87. payload_size = int.from_bytes(payload[:4], "big", signed=True)
  88. payload_msg = payload[4:]
  89. elif message_type == SERVER_ACK:
  90. seq = int.from_bytes(payload[:4], "big", signed=True)
  91. result['seq'] = seq
  92. if len(payload) >= 8:
  93. payload_size = int.from_bytes(payload[4:8], "big", signed=False)
  94. payload_msg = payload[8:]
  95. elif message_type == SERVER_ERROR_RESPONSE:
  96. code = int.from_bytes(payload[:4], "big", signed=False)
  97. result['code'] = code
  98. payload_size = int.from_bytes(payload[4:8], "big", signed=False)
  99. payload_msg = payload[8:]
  100. if payload_msg is None:
  101. return result
  102. if message_compression == GZIP_COMPRESSION:
  103. payload_msg = gzip.decompress(payload_msg)
  104. if serialization_method == JSON_SERIALIZATION:
  105. payload_msg = json.loads(str(payload_msg, "utf-8"))
  106. elif serialization_method != NO_SERIALIZATION:
  107. payload_msg = str(payload_msg, "utf-8")
  108. result['payload_msg'] = payload_msg
  109. result['payload_size'] = payload_size
  110. return result
  111. def _convert_webm_to_pcm(webm_data: bytes) -> bytes:
  112. """
  113. 使用 ffmpeg 将 WebM/Opus 音频转为 PCM 16kHz 16-bit 单声道。
  114. 返回原始 PCM 字节。
  115. """
  116. inp_path = None
  117. out_path = None
  118. try:
  119. with tempfile.NamedTemporaryFile(suffix='.webm', delete=False) as inp:
  120. inp.write(webm_data)
  121. inp_path = inp.name
  122. out_path = inp_path.replace('.webm', '.pcm')
  123. subprocess.run(
  124. [
  125. 'ffmpeg', '-y', '-i', inp_path,
  126. '-f', 's16le', '-ar', '16000', '-ac', '1',
  127. out_path
  128. ],
  129. check=True,
  130. capture_output=True,
  131. )
  132. with open(out_path, 'rb') as f:
  133. return f.read()
  134. finally:
  135. if inp_path and os.path.exists(inp_path):
  136. os.unlink(inp_path)
  137. if out_path and os.path.exists(out_path):
  138. os.unlink(out_path)
  139. async def transcribe_audio(audio_data: bytes) -> str:
  140. """
  141. 将音频二进制数据 (WebM/Opus) 通过火山引擎大模型 ASR 转写为文本。
  142. 流程:
  143. 1. ffmpeg 转码 WebM → PCM 16kHz 16bit mono
  144. 2. WebSocket 连接 ASR 服务
  145. 3. 发送 FULL_CLIENT_REQUEST(音频参数)
  146. 4. 分片发送 AUDIO_ONLY_REQUEST(音频数据)
  147. 5. 发送结束帧
  148. 6. 接收并拼接识别结果
  149. """
  150. config = Config()
  151. if not config.VOLC_ASR_API_KEY:
  152. raise ValueError("ASR 配置缺失: 请设置 VOLC_ASR_API_KEY 环境变量")
  153. # 1. 转码为 PCM
  154. pcm_data = await asyncio.get_event_loop().run_in_executor(
  155. None, _convert_webm_to_pcm, audio_data
  156. )
  157. if not pcm_data:
  158. raise ValueError("音频转码失败: PCM 数据为空")
  159. # 2. 建立 WebSocket 连接
  160. connect_id = str(uuid.uuid4())
  161. headers = {
  162. "X-Api-Key": config.VOLC_ASR_API_KEY,
  163. "X-Api-Resource-Id": config.VOLC_ASR_RESOURCE_ID,
  164. "X-Api-Connect-Id": connect_id,
  165. }
  166. # 3. 构建初始请求参数
  167. request_params = {
  168. "user": {
  169. "uid": connect_id,
  170. },
  171. "audio": {
  172. "format": "pcm",
  173. "rate": 16000,
  174. "bits": 16,
  175. "channel": 1,
  176. "codec": "raw",
  177. },
  178. "request": {
  179. "model_name": "bigmodel",
  180. "enable_itn": True,
  181. "result_type": "full",
  182. }
  183. }
  184. final_text = ""
  185. async with websockets.connect(
  186. ASR_WS_URL,
  187. extra_headers=headers,
  188. max_size=10 * 1024 * 1024,
  189. ) as ws:
  190. # 发送 FULL_CLIENT_REQUEST
  191. payload_bytes = gzip.compress(json.dumps(request_params).encode('utf-8'))
  192. full_request = bytearray(_generate_header(
  193. message_type=FULL_CLIENT_REQUEST,
  194. message_type_specific_flags=POS_SEQUENCE,
  195. ))
  196. full_request.extend(_generate_before_payload(sequence=1))
  197. full_request.extend(len(payload_bytes).to_bytes(4, 'big'))
  198. full_request.extend(payload_bytes)
  199. await ws.send(full_request)
  200. # 接收 ACK
  201. res = await ws.recv()
  202. ack_result = _parse_response(res)
  203. if ack_result.get('message_type') == SERVER_ERROR_RESPONSE:
  204. raise RuntimeError(f"ASR 连接错误: {ack_result.get('payload_msg', ack_result)}")
  205. # 4. 分片发送音频数据 (每片 3200 bytes ≈ 100ms @16kHz 16bit mono)
  206. chunk_size = 3200
  207. seq = 2
  208. total_chunks = (len(pcm_data) + chunk_size - 1) // chunk_size
  209. for i in range(0, len(pcm_data), chunk_size):
  210. chunk = pcm_data[i:i + chunk_size]
  211. is_last = (i + chunk_size >= len(pcm_data))
  212. compressed_chunk = gzip.compress(chunk)
  213. if is_last:
  214. # 最后一帧用 NEG_WITH_SEQUENCE 标志
  215. audio_request = bytearray(_generate_header(
  216. message_type=AUDIO_ONLY_REQUEST,
  217. message_type_specific_flags=NEG_WITH_SEQUENCE,
  218. serial_method=NO_SERIALIZATION,
  219. compression_type=GZIP_COMPRESSION,
  220. ))
  221. audio_request.extend(_generate_before_payload(sequence=-seq))
  222. else:
  223. audio_request = bytearray(_generate_header(
  224. message_type=AUDIO_ONLY_REQUEST,
  225. message_type_specific_flags=POS_SEQUENCE,
  226. serial_method=NO_SERIALIZATION,
  227. compression_type=GZIP_COMPRESSION,
  228. ))
  229. audio_request.extend(_generate_before_payload(sequence=seq))
  230. audio_request.extend(len(compressed_chunk).to_bytes(4, 'big'))
  231. audio_request.extend(compressed_chunk)
  232. await ws.send(audio_request)
  233. # 接收中间结果
  234. res = await ws.recv()
  235. result = _parse_response(res)
  236. if result.get('message_type') == SERVER_ERROR_RESPONSE:
  237. raise RuntimeError(f"ASR 识别错误: {result.get('payload_msg', result)}")
  238. # 提取文本结果
  239. if 'payload_msg' in result and isinstance(result['payload_msg'], dict):
  240. text = result['payload_msg'].get('result', {}).get('text', '')
  241. if text:
  242. final_text = text # 每次全量更新(result_type=full)
  243. seq += 1
  244. # 如果是最后一帧且收到结束标记
  245. if result.get('is_last_package'):
  246. break
  247. # 控制发送速率,避免过快
  248. if not is_last:
  249. await asyncio.sleep(0.02)
  250. # 5. 如果还没收到最终结果,继续接收
  251. if not final_text or not _parse_response(res).get('is_last_package', False):
  252. try:
  253. while True:
  254. res = await asyncio.wait_for(ws.recv(), timeout=5.0)
  255. result = _parse_response(res)
  256. if 'payload_msg' in result and isinstance(result['payload_msg'], dict):
  257. text = result['payload_msg'].get('result', {}).get('text', '')
  258. if text:
  259. final_text = text
  260. if result.get('is_last_package'):
  261. break
  262. except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosed):
  263. pass
  264. return final_text.strip()