|
|
@@ -0,0 +1,313 @@
|
|
|
+"""
|
|
|
+火山引擎大模型流式语音识别 (ASR) 客户端
|
|
|
+协议地址: wss://openspeech.bytedance.com/api/v3/sauc/bigmodel
|
|
|
+文档参考: https://www.volcengine.com/docs/6561/1354869
|
|
|
+"""
|
|
|
+
|
|
|
+import asyncio
|
|
|
+import gzip
|
|
|
+import json
|
|
|
+import uuid
|
|
|
+import subprocess
|
|
|
+import tempfile
|
|
|
+import os
|
|
|
+
|
|
|
+import websockets
|
|
|
+
|
|
|
+from ..config.config import Config
|
|
|
+
|
|
|
+# ============ 协议常量 ============
|
|
|
+PROTOCOL_VERSION = 0b0001
|
|
|
+DEFAULT_HEADER_SIZE = 0b0001
|
|
|
+
|
|
|
+# Message Type
|
|
|
+FULL_CLIENT_REQUEST = 0b0001
|
|
|
+AUDIO_ONLY_REQUEST = 0b0010
|
|
|
+FULL_SERVER_RESPONSE = 0b1001
|
|
|
+SERVER_ACK = 0b1011
|
|
|
+SERVER_ERROR_RESPONSE = 0b1111
|
|
|
+
|
|
|
+# Message Type Specific Flags
|
|
|
+NO_SEQUENCE = 0b0000
|
|
|
+POS_SEQUENCE = 0b0001
|
|
|
+NEG_SEQUENCE = 0b0010
|
|
|
+NEG_WITH_SEQUENCE = 0b0011
|
|
|
+
|
|
|
+# Message Serialization
|
|
|
+NO_SERIALIZATION = 0b0000
|
|
|
+JSON_SERIALIZATION = 0b0001
|
|
|
+
|
|
|
+# Message Compression
|
|
|
+NO_COMPRESSION = 0b0000
|
|
|
+GZIP_COMPRESSION = 0b0001
|
|
|
+
|
|
|
+# ASR 服务地址
|
|
|
+ASR_WS_URL = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel"
|
|
|
+
|
|
|
+
|
|
|
+def _generate_header(
|
|
|
+ message_type=FULL_CLIENT_REQUEST,
|
|
|
+ message_type_specific_flags=NO_SEQUENCE,
|
|
|
+ serial_method=JSON_SERIALIZATION,
|
|
|
+ compression_type=GZIP_COMPRESSION,
|
|
|
+ reserved_data=0x00,
|
|
|
+) -> bytearray:
|
|
|
+ """
|
|
|
+ 生成协议头 (4 bytes):
|
|
|
+ - protocol_version (4 bits) + header_size (4 bits)
|
|
|
+ - message_type (4 bits) + message_type_specific_flags (4 bits)
|
|
|
+ - serialization_method (4 bits) + message_compression (4 bits)
|
|
|
+ - reserved (8 bits)
|
|
|
+ """
|
|
|
+ header = bytearray()
|
|
|
+ header_size = 1
|
|
|
+ header.append((PROTOCOL_VERSION << 4) | header_size)
|
|
|
+ header.append((message_type << 4) | message_type_specific_flags)
|
|
|
+ header.append((serial_method << 4) | compression_type)
|
|
|
+ header.append(reserved_data)
|
|
|
+ return header
|
|
|
+
|
|
|
+
|
|
|
+def _generate_before_payload(sequence: int) -> bytearray:
|
|
|
+ """添加序列号信息 (4 bytes, big-endian, signed)"""
|
|
|
+ before_payload = bytearray()
|
|
|
+ before_payload.extend(sequence.to_bytes(4, 'big', signed=True))
|
|
|
+ return before_payload
|
|
|
+
|
|
|
+
|
|
|
+def _parse_response(res: bytes) -> dict:
|
|
|
+ """解析服务器响应帧"""
|
|
|
+ protocol_version = res[0] >> 4
|
|
|
+ header_size = res[0] & 0x0f
|
|
|
+ message_type = res[1] >> 4
|
|
|
+ message_type_specific_flags = res[1] & 0x0f
|
|
|
+ serialization_method = res[2] >> 4
|
|
|
+ message_compression = res[2] & 0x0f
|
|
|
+ reserved = res[3]
|
|
|
+ header_extensions = res[4:header_size * 4]
|
|
|
+ payload = res[header_size * 4:]
|
|
|
+
|
|
|
+ result = {
|
|
|
+ 'is_last_package': False,
|
|
|
+ 'message_type': message_type,
|
|
|
+ }
|
|
|
+ payload_msg = None
|
|
|
+ payload_size = 0
|
|
|
+
|
|
|
+ if message_type_specific_flags & 0x01:
|
|
|
+ seq = int.from_bytes(payload[:4], "big", signed=True)
|
|
|
+ result['payload_sequence'] = seq
|
|
|
+ payload = payload[4:]
|
|
|
+
|
|
|
+ if message_type_specific_flags & 0x02:
|
|
|
+ result['is_last_package'] = True
|
|
|
+
|
|
|
+ if message_type == FULL_SERVER_RESPONSE:
|
|
|
+ payload_size = int.from_bytes(payload[:4], "big", signed=True)
|
|
|
+ payload_msg = payload[4:]
|
|
|
+ elif message_type == SERVER_ACK:
|
|
|
+ seq = int.from_bytes(payload[:4], "big", signed=True)
|
|
|
+ result['seq'] = seq
|
|
|
+ if len(payload) >= 8:
|
|
|
+ payload_size = int.from_bytes(payload[4:8], "big", signed=False)
|
|
|
+ payload_msg = payload[8:]
|
|
|
+ elif message_type == SERVER_ERROR_RESPONSE:
|
|
|
+ code = int.from_bytes(payload[:4], "big", signed=False)
|
|
|
+ result['code'] = code
|
|
|
+ payload_size = int.from_bytes(payload[4:8], "big", signed=False)
|
|
|
+ payload_msg = payload[8:]
|
|
|
+
|
|
|
+ if payload_msg is None:
|
|
|
+ return result
|
|
|
+
|
|
|
+ if message_compression == GZIP_COMPRESSION:
|
|
|
+ payload_msg = gzip.decompress(payload_msg)
|
|
|
+ if serialization_method == JSON_SERIALIZATION:
|
|
|
+ payload_msg = json.loads(str(payload_msg, "utf-8"))
|
|
|
+ elif serialization_method != NO_SERIALIZATION:
|
|
|
+ payload_msg = str(payload_msg, "utf-8")
|
|
|
+
|
|
|
+ result['payload_msg'] = payload_msg
|
|
|
+ result['payload_size'] = payload_size
|
|
|
+ return result
|
|
|
+
|
|
|
+
|
|
|
+def _convert_webm_to_pcm(webm_data: bytes) -> bytes:
|
|
|
+ """
|
|
|
+ 使用 ffmpeg 将 WebM/Opus 音频转为 PCM 16kHz 16-bit 单声道。
|
|
|
+ 返回原始 PCM 字节。
|
|
|
+ """
|
|
|
+ inp_path = None
|
|
|
+ out_path = None
|
|
|
+ try:
|
|
|
+ with tempfile.NamedTemporaryFile(suffix='.webm', delete=False) as inp:
|
|
|
+ inp.write(webm_data)
|
|
|
+ inp_path = inp.name
|
|
|
+ out_path = inp_path.replace('.webm', '.pcm')
|
|
|
+
|
|
|
+ subprocess.run(
|
|
|
+ [
|
|
|
+ 'ffmpeg', '-y', '-i', inp_path,
|
|
|
+ '-f', 's16le', '-ar', '16000', '-ac', '1',
|
|
|
+ out_path
|
|
|
+ ],
|
|
|
+ check=True,
|
|
|
+ capture_output=True,
|
|
|
+ )
|
|
|
+ with open(out_path, 'rb') as f:
|
|
|
+ return f.read()
|
|
|
+ finally:
|
|
|
+ if inp_path and os.path.exists(inp_path):
|
|
|
+ os.unlink(inp_path)
|
|
|
+ if out_path and os.path.exists(out_path):
|
|
|
+ os.unlink(out_path)
|
|
|
+
|
|
|
+
|
|
|
+async def transcribe_audio(audio_data: bytes) -> str:
|
|
|
+ """
|
|
|
+ 将音频二进制数据 (WebM/Opus) 通过火山引擎大模型 ASR 转写为文本。
|
|
|
+
|
|
|
+ 流程:
|
|
|
+ 1. ffmpeg 转码 WebM → PCM 16kHz 16bit mono
|
|
|
+ 2. WebSocket 连接 ASR 服务
|
|
|
+ 3. 发送 FULL_CLIENT_REQUEST(音频参数)
|
|
|
+ 4. 分片发送 AUDIO_ONLY_REQUEST(音频数据)
|
|
|
+ 5. 发送结束帧
|
|
|
+ 6. 接收并拼接识别结果
|
|
|
+ """
|
|
|
+ config = Config()
|
|
|
+
|
|
|
+ if not config.VOLC_ASR_API_KEY:
|
|
|
+ raise ValueError("ASR 配置缺失: 请设置 VOLC_ASR_API_KEY 环境变量")
|
|
|
+
|
|
|
+ # 1. 转码为 PCM
|
|
|
+ pcm_data = await asyncio.get_event_loop().run_in_executor(
|
|
|
+ None, _convert_webm_to_pcm, audio_data
|
|
|
+ )
|
|
|
+
|
|
|
+ if not pcm_data:
|
|
|
+ raise ValueError("音频转码失败: PCM 数据为空")
|
|
|
+
|
|
|
+ # 2. 建立 WebSocket 连接
|
|
|
+ connect_id = str(uuid.uuid4())
|
|
|
+ headers = {
|
|
|
+ "X-Api-Key": config.VOLC_ASR_API_KEY,
|
|
|
+ "X-Api-Resource-Id": config.VOLC_ASR_RESOURCE_ID,
|
|
|
+ "X-Api-Connect-Id": connect_id,
|
|
|
+ }
|
|
|
+
|
|
|
+ # 3. 构建初始请求参数
|
|
|
+ request_params = {
|
|
|
+ "user": {
|
|
|
+ "uid": connect_id,
|
|
|
+ },
|
|
|
+ "audio": {
|
|
|
+ "format": "pcm",
|
|
|
+ "rate": 16000,
|
|
|
+ "bits": 16,
|
|
|
+ "channel": 1,
|
|
|
+ "codec": "raw",
|
|
|
+ },
|
|
|
+ "request": {
|
|
|
+ "model_name": "bigmodel",
|
|
|
+ "enable_itn": True,
|
|
|
+ "result_type": "full",
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ final_text = ""
|
|
|
+
|
|
|
+ async with websockets.connect(
|
|
|
+ ASR_WS_URL,
|
|
|
+ extra_headers=headers,
|
|
|
+ max_size=10 * 1024 * 1024,
|
|
|
+ ) as ws:
|
|
|
+ # 发送 FULL_CLIENT_REQUEST
|
|
|
+ payload_bytes = gzip.compress(json.dumps(request_params).encode('utf-8'))
|
|
|
+ full_request = bytearray(_generate_header(
|
|
|
+ message_type=FULL_CLIENT_REQUEST,
|
|
|
+ message_type_specific_flags=POS_SEQUENCE,
|
|
|
+ ))
|
|
|
+ full_request.extend(_generate_before_payload(sequence=1))
|
|
|
+ full_request.extend(len(payload_bytes).to_bytes(4, 'big'))
|
|
|
+ full_request.extend(payload_bytes)
|
|
|
+ await ws.send(full_request)
|
|
|
+
|
|
|
+ # 接收 ACK
|
|
|
+ res = await ws.recv()
|
|
|
+ ack_result = _parse_response(res)
|
|
|
+ if ack_result.get('message_type') == SERVER_ERROR_RESPONSE:
|
|
|
+ raise RuntimeError(f"ASR 连接错误: {ack_result.get('payload_msg', ack_result)}")
|
|
|
+
|
|
|
+ # 4. 分片发送音频数据 (每片 3200 bytes ≈ 100ms @16kHz 16bit mono)
|
|
|
+ chunk_size = 3200
|
|
|
+ seq = 2
|
|
|
+ total_chunks = (len(pcm_data) + chunk_size - 1) // chunk_size
|
|
|
+
|
|
|
+ for i in range(0, len(pcm_data), chunk_size):
|
|
|
+ chunk = pcm_data[i:i + chunk_size]
|
|
|
+ is_last = (i + chunk_size >= len(pcm_data))
|
|
|
+
|
|
|
+ compressed_chunk = gzip.compress(chunk)
|
|
|
+
|
|
|
+ if is_last:
|
|
|
+ # 最后一帧用 NEG_WITH_SEQUENCE 标志
|
|
|
+ audio_request = bytearray(_generate_header(
|
|
|
+ message_type=AUDIO_ONLY_REQUEST,
|
|
|
+ message_type_specific_flags=NEG_WITH_SEQUENCE,
|
|
|
+ serial_method=NO_SERIALIZATION,
|
|
|
+ compression_type=GZIP_COMPRESSION,
|
|
|
+ ))
|
|
|
+ audio_request.extend(_generate_before_payload(sequence=-seq))
|
|
|
+ else:
|
|
|
+ audio_request = bytearray(_generate_header(
|
|
|
+ message_type=AUDIO_ONLY_REQUEST,
|
|
|
+ message_type_specific_flags=POS_SEQUENCE,
|
|
|
+ serial_method=NO_SERIALIZATION,
|
|
|
+ compression_type=GZIP_COMPRESSION,
|
|
|
+ ))
|
|
|
+ audio_request.extend(_generate_before_payload(sequence=seq))
|
|
|
+
|
|
|
+ audio_request.extend(len(compressed_chunk).to_bytes(4, 'big'))
|
|
|
+ audio_request.extend(compressed_chunk)
|
|
|
+ await ws.send(audio_request)
|
|
|
+
|
|
|
+ # 接收中间结果
|
|
|
+ res = await ws.recv()
|
|
|
+ result = _parse_response(res)
|
|
|
+
|
|
|
+ if result.get('message_type') == SERVER_ERROR_RESPONSE:
|
|
|
+ raise RuntimeError(f"ASR 识别错误: {result.get('payload_msg', result)}")
|
|
|
+
|
|
|
+ # 提取文本结果
|
|
|
+ if 'payload_msg' in result and isinstance(result['payload_msg'], dict):
|
|
|
+ text = result['payload_msg'].get('result', {}).get('text', '')
|
|
|
+ if text:
|
|
|
+ final_text = text # 每次全量更新(result_type=full)
|
|
|
+
|
|
|
+ seq += 1
|
|
|
+
|
|
|
+ # 如果是最后一帧且收到结束标记
|
|
|
+ if result.get('is_last_package'):
|
|
|
+ break
|
|
|
+
|
|
|
+ # 控制发送速率,避免过快
|
|
|
+ if not is_last:
|
|
|
+ await asyncio.sleep(0.02)
|
|
|
+
|
|
|
+ # 5. 如果还没收到最终结果,继续接收
|
|
|
+ if not final_text or not _parse_response(res).get('is_last_package', False):
|
|
|
+ try:
|
|
|
+ while True:
|
|
|
+ res = await asyncio.wait_for(ws.recv(), timeout=5.0)
|
|
|
+ result = _parse_response(res)
|
|
|
+ if 'payload_msg' in result and isinstance(result['payload_msg'], dict):
|
|
|
+ text = result['payload_msg'].get('result', {}).get('text', '')
|
|
|
+ if text:
|
|
|
+ final_text = text
|
|
|
+ if result.get('is_last_package'):
|
|
|
+ break
|
|
|
+ except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosed):
|
|
|
+ pass
|
|
|
+
|
|
|
+ return final_text.strip()
|