feat(ner): 新增 NER 命名实体识别功能
Validation And Formatting / HACS (push) Has been cancelled
Validation And Formatting / Hassfest (push) Has been cancelled

- 新增 NER 接口支持 (ner.py)
- 新增 SAMI 认证模块 (sami.py)
- 新增 Wave 加密协议客户端 (wave_client.py)
- 扩展 ASR 响应结构支持详细识别结果
- 扩展设备凭据支持 sami_token 和 wave_session
- 重构 AudioEncoder 为同步实现,优化异步文件操作
- 移除不安全的 SSL 证书验证禁用
This commit is contained in:
2026-05-01 22:06:11 +08:00
parent a8c13fd908
commit 6f28d70b0a
9 changed files with 978 additions and 101 deletions
@@ -1,6 +1,11 @@
from .asr import (
DoubaoASR,
ASRResponse,
ASRResult,
ASRAlternative,
ASRWord,
ASRExtra,
OIDecodingInfo,
ASRError,
ResponseType,
AudioChunk,
@@ -9,15 +14,26 @@ from .asr import (
transcribe_realtime,
)
from .config import ASRConfig
from .ner import NerResponse, NerResult, NerWord, ner, async_ner
__all__ = [
"DoubaoASR",
"ASRConfig",
"ASRResponse",
"ASRResult",
"ASRAlternative",
"ASRWord",
"ASRExtra",
"OIDecodingInfo",
"ASRError",
"ResponseType",
"AudioChunk",
"transcribe",
"transcribe_stream",
"transcribe_realtime",
"NerResponse",
"NerResult",
"NerWord",
"ner",
"async_ner",
]
@@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio
import contextlib
from dataclasses import dataclass
from dataclasses import dataclass, field
from enum import Enum, auto
import json
from pathlib import Path
@@ -37,6 +37,59 @@ class ResponseType(Enum):
UNKNOWN = auto()
@dataclass
class ASRWord:
"""单词级别的识别结果"""
word: str
start_time: float
end_time: float
@dataclass
class OIDecodingInfo:
"""OI 解码信息"""
oi_former_word_num: int = 0
oi_latter_word_num: int = 0
oi_words: Optional[List] = None
@dataclass
class ASRAlternative:
"""识别候选结果"""
text: str
start_time: float
end_time: float
words: List[ASRWord] = field(default_factory=list)
semantic_related_to_prev: Optional[bool] = None
oi_decoding_info: Optional[OIDecodingInfo] = None
@dataclass
class ASRResult:
"""单条识别结果"""
text: str
start_time: float
end_time: float
confidence: float = 0.0
alternatives: List[ASRAlternative] = field(default_factory=list)
is_interim: bool = True
is_vad_finished: bool = False
index: int = 0
@dataclass
class ASRExtra:
"""响应附加信息"""
audio_duration: Optional[int] = None
model_avg_rtf: Optional[float] = None
model_send_first_response: Optional[int] = None
speech_adaptation_version: Optional[str] = None
model_total_process_time: Optional[int] = None
packet_number: Optional[int] = None
vad_start: Optional[bool] = None
req_payload: Optional[dict] = None
@dataclass
class ASRResponse:
"""
@@ -50,6 +103,8 @@ class ASRResponse:
packet_number: int = -1
error_msg: str = ""
raw_json: Optional[dict] = None
results: List[ASRResult] = field(default_factory=list)
extra: Optional[ASRExtra] = None
class ASRError(Exception):
@@ -71,6 +126,14 @@ class _SessionState(BaseModel):
error: Optional[ASRResponse] = None
def _create_ssl_context() -> ssl.SSLContext:
"""
在线程中创建并初始化 SSL 上下文,避免阻塞事件循环
"""
ctx = ssl.create_default_context()
return ctx
class DoubaoASR:
"""
豆包输入法 ASR 客户端
@@ -86,13 +149,13 @@ class DoubaoASR:
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
pass
async def _ensure_ssl_context(self):
async def _get_ssl_context(self) -> ssl.SSLContext:
"""
确保 SSL 上下文已创建(在 executor 中运行避免阻塞
获取预初始化的 SSL 上下文(懒加载,在线程中创建
"""
if self._ssl_context is None:
loop = asyncio.get_event_loop()
self._ssl_context = await loop.run_in_executor(None, ssl.create_default_context)
self._ssl_context = await asyncio.to_thread(_create_ssl_context)
return self._ssl_context
async def transcribe(self, audio: Union[str, Path, bytes], *, realtime = False, on_interim: Callable[[str], None] = None) -> str:
"""
@@ -127,21 +190,18 @@ class DoubaoASR:
else:
pcm_data = audio
opus_frames = await self._encoder.pcm_to_opus_frames(pcm_data)
opus_frames = self._encoder.pcm_to_opus_frames(pcm_data)
state = _SessionState()
# 预创建 SSL 上下文避免阻塞
await self._ensure_ssl_context()
# 获取 WebSocket URL
ws_url = await self.config.get_ws_url()
ws_url = await self.config.async_ws_url()
ssl_context = await self._get_ssl_context()
try:
async with websockets.connect(
ws_url,
additional_headers=self.config.headers,
open_timeout=self.config.connect_timeout,
ssl=self._ssl_context,
ssl=ssl_context,
) as ws:
# 初始化会话
async for resp in self._initialize_session(ws, state):
@@ -207,18 +267,15 @@ class DoubaoASR:
"""
state = _SessionState()
# 预创建 SSL 上下文避免阻塞
await self._ensure_ssl_context()
# 获取 WebSocket URL
ws_url = await self.config.get_ws_url()
ws_url = await self.config.async_ws_url()
ssl_context = await self._get_ssl_context()
try:
async with websockets.connect(
ws_url,
additional_headers=self.config.headers,
open_timeout=self.config.connect_timeout,
ssl=self._ssl_context,
ssl=ssl_context,
) as ws:
# 初始化会话
async for resp in self._initialize_session(ws, state):
@@ -270,9 +327,6 @@ class DoubaoASR:
"""
从异步迭代器读取 PCM 数据并实时发送
"""
# 预先获取编码器,避免在循环中触发阻塞导入
encoder = await self._encoder.get_encoder()
timestamp_ms = int(time.time() * 1000)
frame_index = 0
pcm_buffer = b""
@@ -294,7 +348,7 @@ class DoubaoASR:
pcm_buffer = pcm_buffer[bytes_per_frame:]
# 编码为 Opus
opus_frame = encoder.encode(pcm_frame, samples_per_frame)
opus_frame = self._encoder.encoder.encode(pcm_frame, samples_per_frame)
# 确定帧状态(实时模式下不知道最后一帧,使用 FIRST/MIDDLE
if frame_index == 0:
@@ -317,7 +371,7 @@ class DoubaoASR:
if len(pcm_buffer) < bytes_per_frame:
pcm_buffer += b"\x00" * (bytes_per_frame - len(pcm_buffer))
opus_frame = encoder.encode(pcm_buffer, samples_per_frame)
opus_frame = self._encoder.encoder.encode(pcm_buffer, samples_per_frame)
msg = _build_asr_request(
opus_frame,
@@ -330,7 +384,7 @@ class DoubaoASR:
# 没有剩余数据,但需要发送一个 LAST 帧标记
# 发送一个空的 LAST 帧(静音)
silent_frame = b"\x00" * bytes_per_frame
opus_frame = encoder.encode(silent_frame, samples_per_frame)
opus_frame = self._encoder.encoder.encode(silent_frame, samples_per_frame)
msg = _build_asr_request(
opus_frame,
@@ -342,14 +396,14 @@ class DoubaoASR:
# FinishSession
if not state.is_finished:
token = await self.config.get_token()
token = await self.config.async_get_token()
await ws.send(_build_finish_session(state.request_id, token))
async def _initialize_session(self, ws: ClientConnection, state: _SessionState) -> AsyncIterator[ASRResponse]:
"""
初始化 ASR 会话
"""
token = await self.config.get_token()
token = await self.config.async_get_token()
# StartTask
await ws.send(_build_start_task(state.request_id, token))
@@ -360,9 +414,9 @@ class DoubaoASR:
yield parsed
# StartSession
session_config = await self.config.get_session_config()
session_cfg = await self.config.async_session_config()
await ws.send(
_build_start_session(state.request_id, token, session_config)
_build_start_session(state.request_id, token, session_cfg)
)
resp = await ws.recv()
parsed = _parse_response(resp)
@@ -406,7 +460,7 @@ class DoubaoASR:
await asyncio.sleep(frame_interval)
# FinishSession
token = await self.config.get_token()
token = await self.config.async_get_token()
await ws.send(_build_finish_session(state.request_id, token))
async def _receive_responses(
@@ -499,6 +553,68 @@ def _build_asr_request(
def _parse_word(data: dict) -> ASRWord:
"""解析单词数据"""
return ASRWord(
word=data.get("word", ""),
start_time=data.get("start_time", 0.0),
end_time=data.get("end_time", 0.0),
)
def _parse_oi_decoding_info(data: Optional[dict]) -> Optional[OIDecodingInfo]:
"""解析 OI 解码信息"""
if data is None:
return None
return OIDecodingInfo(
oi_former_word_num=data.get("oi_former_word_num", 0),
oi_latter_word_num=data.get("oi_latter_word_num", 0),
oi_words=data.get("oi_words"),
)
def _parse_alternative(data: dict) -> ASRAlternative:
"""解析候选结果"""
words = [_parse_word(w) for w in data.get("words", [])]
return ASRAlternative(
text=data.get("text", ""),
start_time=data.get("start_time", 0.0),
end_time=data.get("end_time", 0.0),
words=words,
semantic_related_to_prev=data.get("semantic_related_to_prev"),
oi_decoding_info=_parse_oi_decoding_info(data.get("oi_decoding_info")),
)
def _parse_result(data: dict) -> ASRResult:
"""解析单条识别结果"""
alternatives = [_parse_alternative(a) for a in data.get("alternatives", [])]
return ASRResult(
text=data.get("text", ""),
start_time=data.get("start_time", 0.0),
end_time=data.get("end_time", 0.0),
confidence=data.get("confidence", 0.0),
alternatives=alternatives,
is_interim=data.get("is_interim", True),
is_vad_finished=data.get("is_vad_finished", False),
index=data.get("index", 0),
)
def _parse_extra(data: dict) -> ASRExtra:
"""解析附加信息"""
return ASRExtra(
audio_duration=data.get("audio_duration"),
model_avg_rtf=data.get("model_avg_rtf"),
model_send_first_response=data.get("model_send_first_response"),
speech_adaptation_version=data.get("speech_adaptation_version"),
model_total_process_time=data.get("model_total_process_time"),
packet_number=data.get("packet_number"),
vad_start=data.get("vad_start"),
req_payload=data.get("req_payload"),
)
def _parse_response(data: bytes) -> ASRResponse:
"""解析 ASR 响应 (使用 protobuf)"""
pb = AsrResponsePb()
@@ -530,20 +646,33 @@ def _parse_response(data: bytes) -> ASRResponse:
except json.JSONDecodeError:
return ASRResponse(type=ResponseType.UNKNOWN)
results = json_data.get("results")
extra = json_data.get("extra", {})
results_raw = json_data.get("results")
extra_raw = json_data.get("extra", {})
# 解析为强类型
parsed_extra = _parse_extra(extra_raw)
# 无 results,可能是心跳包
if results is None:
if results_raw is None:
return ASRResponse(
type=ResponseType.HEARTBEAT,
packet_number=extra.get("packet_number", -1),
packet_number=extra_raw.get("packet_number", -1),
raw_json=json_data,
extra=parsed_extra,
)
# 解析 results
parsed_results = [_parse_result(r) for r in results_raw]
# VAD 开始
if extra.get("vad_start"):
return ASRResponse(type=ResponseType.VAD_START, vad_start=True, raw_json=json_data)
if extra_raw.get("vad_start"):
return ASRResponse(
type=ResponseType.VAD_START,
vad_start=True,
raw_json=json_data,
results=parsed_results,
extra=parsed_extra,
)
# 解析识别结果
text = ""
@@ -551,7 +680,7 @@ def _parse_response(data: bytes) -> ASRResponse:
vad_finished = False
nonstream_result = False
for r in results:
for r in results_raw:
if r.get("text"):
text = r.get("text")
if r.get("is_interim") is False:
@@ -569,6 +698,8 @@ def _parse_response(data: bytes) -> ASRResponse:
is_final=True,
vad_finished=vad_finished,
raw_json=json_data,
results=parsed_results,
extra=parsed_extra,
)
# 中间结果
@@ -577,6 +708,8 @@ def _parse_response(data: bytes) -> ASRResponse:
text=text,
is_final=False,
raw_json=json_data,
results=parsed_results,
extra=parsed_extra,
)
@@ -653,4 +786,4 @@ async def transcribe_realtime(
"""
async with DoubaoASR(config) as asr:
async for resp in asr.transcribe_realtime(audio_source):
yield resp
yield resp
@@ -1,13 +1,11 @@
from typing import Optional, List, Union, TYPE_CHECKING
from typing import Optional, List, Union
from pathlib import Path
import asyncio
import opuslib
import miniaudio
from .config import ASRConfig
if TYPE_CHECKING:
import opuslib
class AudioEncoder:
@@ -16,36 +14,19 @@ class AudioEncoder:
"""
def __init__(self, config: ASRConfig) -> None:
self.config = config
self._encoder: Optional["opuslib.Encoder"] = None
self._opuslib_module = None
self._encoder: Optional[opuslib.Encoder] = None
async def _ensure_opuslib(self):
"""在 executor 中导入 opuslib,避免阻塞事件循环"""
if self._opuslib_module is None:
loop = asyncio.get_event_loop()
self._opuslib_module = await loop.run_in_executor(None, self._import_opuslib)
@staticmethod
def _import_opuslib():
"""在独立线程中导入 opuslib"""
import opuslib
return opuslib
async def get_encoder(self) -> "opuslib.Encoder":
"""异步获取编码器"""
@property
def encoder(self) -> opuslib.Encoder:
if self._encoder is None:
await self._ensure_opuslib()
self._encoder = self._opuslib_module.Encoder(
self._encoder = opuslib.Encoder(
self.config.sample_rate,
self.config.channels,
self._opuslib_module.APPLICATION_AUDIO,
opuslib.APPLICATION_AUDIO,
)
return self._encoder
async def pcm_to_opus_frames(self, pcm_data: bytes) -> List[bytes]:
"""将 PCM 数据转换为 Opus 帧(异步)"""
encoder = await self.get_encoder()
def pcm_to_opus_frames(self, pcm_data: bytes) -> List[bytes]:
samples_per_frame = (
self.config.sample_rate * self.config.frame_duration_ms // 1000
)
@@ -57,7 +38,7 @@ class AudioEncoder:
if len(chunk) < bytes_per_frame:
chunk = chunk + b"\x00" * (bytes_per_frame - len(chunk))
opus_frame = encoder.encode(chunk, samples_per_frame)
opus_frame = self.encoder.encode(chunk, samples_per_frame)
frames.append(opus_frame)
return frames
@@ -1,13 +1,34 @@
from dataclasses import dataclass, field
import asyncio
import base64
from dataclasses import dataclass, field
import json
from pathlib import Path
import time
from typing import Optional, Union
from pydantic import BaseModel
import aiofiles
from .constants import WEBSOCKET_URL, USER_AGENT, AID
from .device import DeviceCredentials, register_device, get_asr_token
from .sami import get_sami_token
def _jwt_is_expired(token: str, margin: int = 60) -> bool:
"""
检查 JWT token 是否已过期(提前 margin 秒视为过期)
"""
try:
payload_b64 = token.split(".")[1]
# JWT base64url 需要补齐 padding
padding = 4 - len(payload_b64) % 4
if padding != 4:
payload_b64 += "=" * padding
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
exp = payload.get("exp")
if exp is None:
return False
return time.time() >= exp - margin
except (IndexError, ValueError, json.JSONDecodeError):
return False
class _AudioInfo(BaseModel):
@@ -92,10 +113,11 @@ class ASRConfig:
# 内部状态
_credentials: Optional[DeviceCredentials] = field(default=None, repr=None)
_initialized: bool = field(default=False, repr=False)
_wave_client: Optional[object] = field(default=None, repr=False)
async def _load_credentials_from_file(self) -> Optional[DeviceCredentials]:
def _load_credentials_from_file_sync(self) -> Optional[DeviceCredentials]:
"""
从缓存文件中加载凭据信息(异步
同步的文件读取实现(在线程中调用
"""
if self.credential_path is None:
return None
@@ -105,34 +127,88 @@ class ASRConfig:
return None
try:
async with aiofiles.open(path, 'r', encoding='utf-8') as f:
content = await f.read()
data = json.loads(content)
with open(path, 'r', encoding='utf-8') as f:
data = json.loads(f.read())
return DeviceCredentials(**data)
except (json.JSONDecodeError, OSError):
return None
async def _save_credentials_to_file(self, creds: DeviceCredentials):
def _save_credentials_to_file_sync(self, creds: DeviceCredentials):
"""
保存凭据至缓存文件(异步
同步的文件写入实现(在线程中调用
"""
if self.credential_path is None:
return
path = Path(self.credential_path).expanduser()
# 使用 run_in_executor 来执行同步的 mkdir 操作
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: path.parent.mkdir(parents=True, exist_ok=True))
path.parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(path, 'w', encoding='utf-8') as f:
content = json.dumps(creds.model_dump(), indent=2, ensure_ascii=False)
await f.write(content)
with open(path, 'w', encoding='utf-8') as f:
json.dump(creds.model_dump(), f, indent=2, ensure_ascii=False)
async def _load_credentials_from_file(self) -> Optional[DeviceCredentials]:
"""
从缓存文件中加载凭据信息(异步,不阻塞事件循环)
"""
return await asyncio.to_thread(self._load_credentials_from_file_sync)
async def _save_credentials_to_file(self, creds: DeviceCredentials):
"""
保存凭据至缓存文件(异步,不阻塞事件循环)
"""
await asyncio.to_thread(self._save_credentials_to_file_sync, creds)
def ensure_credentials(self):
"""
确保凭据已初始化(同步版本,仅用于非 async 上下文)
"""
if self._initialized:
return
# 保存直接通过参数传入的凭据,用于进行覆盖
user_device_id = self.device_id
user_token = self.token
# 尝试从文件中加载凭据
file_creds = self._load_credentials_from_file_sync()
if file_creds:
self._credentials = file_creds
# 使用文件中的值作为默认
if self.device_id is None:
self.device_id = file_creds.device_id
if self.token is None:
self.token = file_creds.token
# 如果 device_id 仍为 None, 则注册设备
need_save = False
if self.device_id is None:
self._credentials = register_device()
self.device_id = self._credentials.device_id
need_save = True
# 如果 token 仍为 None, 则获取 token
if self.token is None:
cdid = self._credentials.cdid if self._credentials else None
self.token = get_asr_token(self.device_id, cdid)
# 如果指定了 credential_path 且有新注册的凭据,则保存至文件
if self.credential_path and need_save and self._credentials:
self._credentials.token = self.token
self._save_credentials_to_file_sync(self._credentials)
# 覆盖用户传入的参数
if user_device_id is not None:
self.device_id = user_device_id
if user_token is not None:
self.token = user_token
self._initialized = True
async def async_ensure_credentials(self):
"""
确保凭据已初始化(异步)
确保凭据已初始化(异步版本,不阻塞事件循环
优先级:
1. 直接传入的 device_id/token 参数(最高优先级)
@@ -148,7 +224,7 @@ class ASRConfig:
user_device_id = self.device_id
user_token = self.token
# 尝试从文件中加载凭据
# 尝试从文件中加载凭据(异步)
file_creds = await self._load_credentials_from_file()
if file_creds:
self._credentials = file_creds
@@ -158,23 +234,19 @@ class ASRConfig:
if self.token is None:
self.token = file_creds.token
# 如果 device_id 仍为 None, 则注册设备
# 如果 device_id 仍为 None, 则注册设备(网络 I/O 在线程中执行)
need_save = False
if self.device_id is None:
# 在 executor 中运行同步的 register_device
loop = asyncio.get_event_loop()
self._credentials = await loop.run_in_executor(None, register_device)
self._credentials = await asyncio.to_thread(register_device)
self.device_id = self._credentials.device_id
need_save = True
# 如果 token 仍为 None, 则获取 token
# 如果 token 仍为 None, 则获取 token(网络 I/O 在线程中执行)
if self.token is None:
cdid = self._credentials.cdid if self._credentials else None
# 在 executor 中运行同步的 get_asr_token
loop = asyncio.get_event_loop()
self.token = await loop.run_in_executor(None, get_asr_token, self.device_id, cdid)
self.token = await asyncio.to_thread(get_asr_token, self.device_id, cdid)
# 如果指定了 credential_path 且有新注册的凭据,则保存至文件
# 如果指定了 credential_path 且有新注册的凭据,则保存至文件(异步)
if self.credential_path and need_save and self._credentials:
self._credentials.token = self.token
await self._save_credentials_to_file(self._credentials)
@@ -188,8 +260,18 @@ class ASRConfig:
self._initialized = True
async def get_ws_url(self) -> str:
"""获取 WebSocket URL(异步)"""
@property
def ws_url(self) -> str:
"""
获取 WebSocket URL(同步版本,需确保凭据已初始化)
"""
self.ensure_credentials()
return f'{self.url}?aid={self.aid}&device_id={self.device_id}'
async def async_ws_url(self) -> str:
"""
获取 WebSocket URL(异步版本,不阻塞事件循环)
"""
await self.async_ensure_credentials()
return f'{self.url}?aid={self.aid}&device_id={self.device_id}'
@@ -201,9 +283,24 @@ class ASRConfig:
"x-custom-keepalive": "true"
}
async def get_session_config(self) -> SessionConfig:
"""获取会话配置(异步)"""
def session_config(self) -> SessionConfig:
"""
获取会话配置(同步版本,需确保凭据已初始化)
"""
self.ensure_credentials()
return self._build_session_config()
async def async_session_config(self) -> SessionConfig:
"""
获取会话配置(异步版本,不阻塞事件循环)
"""
await self.async_ensure_credentials()
return self._build_session_config()
def _build_session_config(self) -> SessionConfig:
"""
构建会话配置(内部方法,需确保凭据已初始化后调用)
"""
audio_info = _AudioInfo(
channel=self.channels,
format="speech_opus",
@@ -225,7 +322,114 @@ class ASRConfig:
extra=extra,
)
async def get_token(self) -> str:
"""获取 token(异步)"""
def get_token(self) -> str:
"""
获取 token(同步版本,需确保凭据已初始化)
"""
self.ensure_credentials()
return self.token
async def async_get_token(self) -> str:
"""
获取 token(异步版本,不阻塞事件循环)
"""
await self.async_ensure_credentials()
return self.token
return self.token
def _on_wave_session_update(self, session) -> None:
"""
Wave 会话更新时的回调,将新会话同步到凭据缓存
"""
if self._credentials:
self._credentials.wave_session = session.to_dict()
self._save_credentials_to_file_sync(self._credentials)
def get_wave_client(self):
"""
获取 WaveClient 实例(同步版本,懒加载,自动管理握手)
"""
from .wave_client import WaveClient, WaveSession
self.ensure_credentials()
if self._wave_client is None:
cached_session = None
if self._credentials and self._credentials.wave_session:
try:
session = WaveSession.from_dict(self._credentials.wave_session)
if not session.is_expired():
cached_session = session
except (KeyError, ValueError):
pass
self._wave_client = WaveClient(
self.device_id,
self.aid,
session=cached_session,
on_session_update=self._on_wave_session_update,
)
return self._wave_client
async def async_get_wave_client(self):
"""
获取 WaveClient 实例(异步版本,不阻塞事件循环)
"""
from .wave_client import WaveClient, WaveSession
await self.async_ensure_credentials()
if self._wave_client is None:
cached_session = None
if self._credentials and self._credentials.wave_session:
try:
session = WaveSession.from_dict(self._credentials.wave_session)
if not session.is_expired():
cached_session = session
except (KeyError, ValueError):
pass
self._wave_client = WaveClient(
self.device_id,
self.aid,
session=cached_session,
on_session_update=self._on_wave_session_update,
)
return self._wave_client
def get_sami_token(self) -> str:
"""
获取 SAMI token(同步版本,需确保凭据已初始化)
"""
self.ensure_credentials()
if (self._credentials
and self._credentials.sami_token
and not _jwt_is_expired(self._credentials.sami_token)):
return self._credentials.sami_token
cdid = self._credentials.cdid if self._credentials else None
sami_token = get_sami_token(cdid)
if self._credentials:
self._credentials.sami_token = sami_token
self._save_credentials_to_file_sync(self._credentials)
return sami_token
async def async_get_sami_token(self) -> str:
"""
获取 SAMI token(异步版本,不阻塞事件循环)
"""
await self.async_ensure_credentials()
if (self._credentials
and self._credentials.sami_token
and not _jwt_is_expired(self._credentials.sami_token)):
return self._credentials.sami_token
cdid = self._credentials.cdid if self._credentials else None
sami_token = await asyncio.to_thread(get_sami_token, cdid)
if self._credentials:
self._credentials.sami_token = sami_token
await self._save_credentials_to_file(self._credentials)
return sami_token
@@ -7,9 +7,24 @@ SETTINGS_URL = "https://is.snssdk.com/service/settings/v3/"
# ASR WebSocket URL
WEBSOCKET_URL = "wss://frontier-audio-ime-ws.doubao.com/ocean/api/v1/ws"
# 需要通过该接口获取 SAMI TOKEN 来访问 NER 接口
SAMI_CONFIG_URL = "https://ime.oceancloudapi.com/api/v1/user/get_config"
# 加密协议握手 URL
HANDSHAKE_URL = "https://keyhub.zijieapi.com/handshake"
# NER 接口 URL
NER_URL = "https://speech.bytedance.com/api/v3/context/ime/ner"
# 豆包输入法的 APP ID
AID = 401734
# SAMI 语音相关的服务 APP KEY (写死在客户端的)
SAMI_APP_KEY = "SYlxZr6LnvBaIVmF"
# HKDF info 字符串 (写死在客户端的)
HKDF_INFO = b"4e30514609050cd3"
# 应用配置 (豆包输入法)
APP_CONFIG = {
"aid": AID,
@@ -29,6 +29,14 @@ class DeviceCredentials(BaseModel):
"""
用于 ASR 的 token
"""
sami_token: Optional[str] = None
"""
用于 NER 等 SAMI 服务的 token
"""
wave_session: Optional[dict] = None
"""
Wave 加密会话缓存(序列化后的 WaveSession
"""
class DeviceRegisterHeaderField(BaseModel):
@@ -273,7 +281,6 @@ def register_device() -> DeviceCredentials:
params=params.model_dump(),
json=body.model_dump(),
headers=headers,
verify=False, # 禁用SSL证书验证以兼容某些环境
)
response.raise_for_status()
@@ -311,7 +318,6 @@ def get_asr_token(device_id: str, cdid: str) -> str:
params=params,
data=body_str,
headers=headers,
verify=False, # 禁用SSL证书验证以兼容某些环境
)
response.raise_for_status()
@@ -0,0 +1,126 @@
from __future__ import annotations
import base64
import json
import uuid
from typing import TYPE_CHECKING, List
import requests
from pydantic import BaseModel, Field
from .wave_client import WaveClient
from .constants import AID, APP_CONFIG, NER_URL, SAMI_APP_KEY
if TYPE_CHECKING:
from .config import ASRConfig
class NerUserInfo(BaseModel):
uid: str = "0"
did: str
app_name: str
app_version: str
sdk_version: str = ""
platform: str = "android"
experience_improve: bool = False
@classmethod
def new(cls, did: str, app_name: str):
app_version = APP_CONFIG.get("version_name", "")
return cls(did=did, app_name=app_name, app_version=app_version)
class NerRequest(BaseModel):
"""
ner 接口请求体
"""
user: NerUserInfo
text: str
additions: dict = Field(default_factory=dict)
@classmethod
def new(cls, text: str, did: str, app_name: str = "", addiction: dict = None):
return cls(user=NerUserInfo.new(did, app_name), text=text, additions=addiction or {})
class NerWord(BaseModel):
freq: int
word: str
class NerResult(BaseModel):
text: str
words: List[NerWord]
class NerResponse(BaseModel):
"""
ner 接口响应体
"""
results: List[NerResult]
def get_ner_results(wave_client: WaveClient, sami_token: str, text: str, did: str, app_name: str = "") -> NerResponse:
"""
调用 ner 接口获取结果
"""
request = NerRequest.new(text, did, app_name)
headers = {
'app_version': APP_CONFIG.get('version_name', ''),
'app_id': str(AID),
'os_type': 'android',
'x-api-resource-id': 'asr.user.context',
'x-api-app-key': SAMI_APP_KEY,
'x-api-token': sami_token,
'x-api-request-id': str(uuid.uuid4()),
}
req_data = request.model_dump_json().encode()
payload, headers = wave_client.prepare_request(req_data, headers)
response = requests.post(NER_URL, data=payload, headers=headers)
resp_headers = response.headers
nonce = base64.b64decode(resp_headers.get('x-tt-e-p'))
decoded = wave_client.decrypt(response.content, nonce=nonce)
return NerResponse(**json.loads(decoded.decode()))
def ner(config: ASRConfig, text: str, app_name: str = "") -> NerResponse:
"""
通过 ASRConfig 调用 NER 接口(同步便捷函数)
自动管理 WaveClient、sami_token 和 device_id。
:param config: ASR 配置(会自动初始化凭据)
:param text: 需要进行 NER 的文本
:param app_name: 应用名称(可选),可能会根据不同应用的使用场景适配不同的识别策略?"
:return: NER 响应
"""
config.ensure_credentials()
wave_client = config.get_wave_client()
sami_token = config.get_sami_token()
return get_ner_results(wave_client, sami_token, text, config.device_id, app_name)
async def async_ner(config: ASRConfig, text: str, app_name: str = "") -> NerResponse:
"""
通过 ASRConfig 调用 NER 接口(异步便捷函数,不阻塞事件循环)
自动管理 WaveClient、sami_token 和 device_id。
:param config: ASR 配置(会自动初始化凭据)
:param text: 需要进行 NER 的文本
:param app_name: 应用名称(可选)
:return: NER 响应
"""
import asyncio
await config.async_ensure_credentials()
wave_client = await config.async_get_wave_client()
sami_token = await config.async_get_sami_token()
return await asyncio.to_thread(
get_ner_results, wave_client, sami_token, text, config.device_id, app_name
)
@@ -0,0 +1,137 @@
"""
SAMI 服务相关
"""
import hashlib
import time
import uuid
from typing import Optional
import requests
from pydantic import BaseModel, ConfigDict, Field
from .constants import SAMI_CONFIG_URL, SAMI_APP_KEY, USER_AGENT, APP_CONFIG, DEFAULT_DEVICE_CONFIG
class _SamiConfigParams(BaseModel):
"""
SAMI 配置接口的 URL Params
"""
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
device_platform: str = "android"
os: str = "android"
ssmix: str = "a"
rticket: str = Field(default_factory=lambda: str(int(time.time() * 1000)), serialization_alias="_rticket")
cdid: str
channel: str
aid: str
app_name: str
version_code: str
version_name: str
manifest_version_code: str
update_version_code: str
resolution: str
dpi: str
device_type: str
device_brand: str
language: str
os_api: str
os_version: str
ac: str = "wifi"
use_olympus_account: str = Field(default="1", serialization_alias="use-olympus-account")
@classmethod
def default(cls, cdid: str) -> "_SamiConfigParams":
"""
使用默认配置构建 SAMI 配置 Params
"""
app_config = {
**{k: APP_CONFIG[k] for k in ("channel", "app_name", "version_name")},
"aid": str(APP_CONFIG["aid"]),
"version_code": str(APP_CONFIG["version_code"]),
"manifest_version_code": str(APP_CONFIG["manifest_version_code"]),
"update_version_code": str(APP_CONFIG["update_version_code"]),
}
device_keys = ("device_platform", "os", "resolution", "dpi", "device_type",
"device_brand", "language", "os_api", "os_version")
device_config = {k: DEFAULT_DEVICE_CONFIG[k] for k in device_keys}
return cls(cdid=cdid, **app_config, **device_config)
class _SamiConfigRequest(BaseModel):
"""
SAMI 配置接口的请求体
"""
sami_app_key: str = SAMI_APP_KEY
class _SamiConfigData(BaseModel):
"""SAMI 配置数据"""
sami_token: str
class _SamiConfigResponse(BaseModel):
"""
SAMI 配置接口的响应
"""
code: int
msg: str
data: _SamiConfigData
@property
def sami_token(self) -> str:
return self.data.sami_token
def get_sami_config(cdid: str) -> requests.Response:
"""
获取 SAMI 配置 (包含 token)
Args:
cdid: 客户端设备 ID
Returns:
响应对象
"""
params = _SamiConfigParams.default(cdid)
body = _SamiConfigRequest()
body_json = body.model_dump_json()
x_ss_stub = hashlib.md5(body_json.encode()).hexdigest().upper()
headers = {
"User-Agent": USER_AGENT,
"Content-Type": "application/json",
"app_version": APP_CONFIG["version_name"],
"app_id": str(APP_CONFIG["aid"]),
"os_type": "Android",
"x-ss-stub": x_ss_stub,
}
response = requests.post(
SAMI_CONFIG_URL,
params=params.model_dump(by_alias=True),
data=body_json,
headers=headers,
)
return response
def get_sami_token(cdid: Optional[str] = None) -> str:
"""
获取 SAMI token
:param cdid: 客户端设备 ID,如果为 None 则自动生成
:return: SAMI token 字符串
"""
if cdid is None:
cdid = str(uuid.uuid4())
response = get_sami_config(cdid)
response.raise_for_status()
data = _SamiConfigResponse(**response.json())
return data.sami_token
@@ -0,0 +1,259 @@
"""
ByteDance Wave 加密协议客户端
"""
import base64
import hashlib
import secrets
import time
from typing import Callable, Optional, Union
import requests
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from pydantic import BaseModel
from .constants import HANDSHAKE_URL, HKDF_INFO, USER_AGENT
class _KeyShare(BaseModel):
"""
密钥交换信息
"""
curve: str
pubkey: str
class _HandshakeRequest(BaseModel):
"""
握手请求
"""
version: int = 2
random: str
app_id: str
did: str
key_shares: list[_KeyShare]
cipher_suites: list[int] = [4097] # ChaCha20
class _HandshakeResponse(BaseModel):
"""
握手响应
"""
version: int
random: str
key_share: _KeyShare
cipher_suite: int
cert: str
ticket: str
ticket_exp: int
ticket_long: str
ticket_long_exp: int
class WaveSession(BaseModel):
"""
Wave 加密会话
"""
ticket: str
ticket_long: str
encryption_key: bytes
client_random: bytes
server_random: bytes
shared_key: bytes
ticket_exp: int
ticket_long_exp: int
expires_at: float
class Config:
arbitrary_types_allowed = True
def is_expired(self) -> bool:
"""检查会话是否已过期"""
return time.time() >= self.expires_at
def to_dict(self) -> dict:
"""序列化为可 JSON 存储的 dictbytes 字段用 base64 编码)"""
return {
"ticket": self.ticket,
"ticket_long": self.ticket_long,
"encryption_key": base64.b64encode(self.encryption_key).decode(),
"client_random": base64.b64encode(self.client_random).decode(),
"server_random": base64.b64encode(self.server_random).decode(),
"shared_key": base64.b64encode(self.shared_key).decode(),
"ticket_exp": self.ticket_exp,
"ticket_long_exp": self.ticket_long_exp,
"expires_at": self.expires_at,
}
@classmethod
def from_dict(cls, data: dict) -> "WaveSession":
"""从 dict 反序列化(base64 解码 bytes 字段)"""
return cls(
ticket=data["ticket"],
ticket_long=data["ticket_long"],
encryption_key=base64.b64decode(data["encryption_key"]),
client_random=base64.b64decode(data["client_random"]),
server_random=base64.b64decode(data["server_random"]),
shared_key=base64.b64decode(data["shared_key"]),
ticket_exp=data["ticket_exp"],
ticket_long_exp=data["ticket_long_exp"],
expires_at=data["expires_at"],
)
class WaveClient:
"""
Wave 协议客户端
"""
def __init__(
self,
device_id: str,
app_id: Union[str, int],
session: Optional[WaveSession] = None,
on_session_update: Optional[Callable[[WaveSession], None]] = None,
):
self.device_id = device_id
self.app_id = str(app_id)
self.session = session
self._on_session_update = on_session_update
@staticmethod
def _chacha20_crypt(key: bytes, nonce: bytes, data: bytes) -> bytes:
"""ChaCha20 加密/解密"""
if len(nonce) == 12:
nonce_16 = b'\x00\x00\x00\x00' + nonce
else:
nonce_16 = nonce
cipher = Cipher(algorithms.ChaCha20(key, nonce_16), mode=None, backend=default_backend())
encryptor = cipher.encryptor()
return encryptor.update(data) + encryptor.finalize()
@staticmethod
def _derive_key(shared_key: bytes, salt: bytes, info: bytes) -> bytes:
"""HKDF 密钥派生"""
return HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
info=info,
backend=default_backend()
).derive(shared_key)
def handshake(self) -> bool:
"""
执行 Wave 握手,建立加密会话
:return: 握手是否成功
"""
# 生成 ECDH 密钥对
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
client_random = secrets.token_bytes(32)
pubkey_bytes = private_key.public_key().public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint
)
request = _HandshakeRequest(
random=base64.b64encode(client_random).decode(),
app_id=self.app_id,
did=self.device_id,
key_shares=[_KeyShare(curve="secp256r1", pubkey=base64.b64encode(pubkey_bytes).decode())],
)
request_json = request.model_dump_json(by_alias=True)
# ECDSA 签名
signature = private_key.sign(request_json.encode(), ec.ECDSA(hashes.SHA256()))
headers = {
"Content-Type": "application/json",
"x-tt-s-sign": base64.b64encode(signature).decode(),
"User-Agent": USER_AGENT,
}
response = requests.post(HANDSHAKE_URL, data=request_json, headers=headers)
if response.status_code != 200:
return False
resp = _HandshakeResponse(**response.json())
# 计算共享密钥
server_pubkey = base64.b64decode(resp.key_share.pubkey)
server_public_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), server_pubkey)
shared_key = private_key.exchange(ec.ECDH(), server_public_key)
server_random = base64.b64decode(resp.random)
# 派生加密密钥
salt = client_random + server_random
encryption_key = self._derive_key(shared_key, salt, HKDF_INFO)
# 保存会话,计算绝对过期时间(提前 60 秒刷新)
self.session = WaveSession(
ticket=resp.ticket,
ticket_long=resp.ticket_long,
encryption_key=encryption_key,
client_random=client_random,
server_random=server_random,
shared_key=shared_key,
ticket_exp=resp.ticket_exp,
ticket_long_exp=resp.ticket_long_exp,
expires_at=time.time() + resp.ticket_exp - 60,
)
if self._on_session_update:
self._on_session_update(self.session)
return True
def _ensure_session(self) -> None:
"""确保会话有效,如果过期则自动刷新"""
if self.session is None or self.session.is_expired():
if not self.handshake():
raise RuntimeError("Failed to establish/refresh session")
def prepare_request(self, plaintext: bytes, extra_headers: Optional[dict] = None) -> tuple[bytes, dict]:
"""
准备加密请求
:param plaintext: 明文数据
:param extra_headers: 额外的请求头
:return: (密文, headers) 元组
"""
self._ensure_session()
nonce = secrets.token_bytes(12)
ciphertext = self._chacha20_crypt(self.session.encryption_key, nonce, plaintext)
stub = hashlib.md5(ciphertext).hexdigest().upper()
headers = {
"Content-Type": "application/json",
"x-tt-e-b": "1",
"x-tt-e-t": self.session.ticket,
"x-tt-e-p": base64.b64encode(nonce).decode(),
"x-ss-stub": stub,
}
if extra_headers:
headers.update(extra_headers)
return ciphertext, headers
def decrypt(self, ciphertext: bytes, nonce: bytes) -> bytes:
"""
解密数据
:param ciphertext: 密文数据
:param nonce: 12 字节 nonce
:return: 明文数据
"""
if not self.session:
raise RuntimeError("No active session. Call handshake() first.")
return self._chacha20_crypt(self.session.encryption_key, nonce, ciphertext)