asr_client.py 10 KB

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