```
feat(stt): 新增豆包语音识别集成 - 实现完整的 Doubao STT 组件,支持中文语音识别 - 提供 UI 配置界面,无需手动编辑配置文件 - 支持实时流式识别和自动标点符号添加 - 集成自动设备注册和凭据管理功能 - 添加 HACS 和 hassfest 验证工作流 - 配置 .gitignore 和 Apache 2.0 许可证文件 - 完善 README 文档和故障排除指南 ```
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
"""The Doubao Speech-to-Text integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
CONF_CREDENTIAL_PATH,
|
||||
CONF_ENABLE_PUNCTUATION,
|
||||
DEFAULT_CREDENTIAL_PATH,
|
||||
DEFAULT_ENABLE_PUNCTUATION,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.STT]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Doubao Speech-to-Text from a config entry."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
# 获取配置
|
||||
credential_path = entry.data.get(CONF_CREDENTIAL_PATH, DEFAULT_CREDENTIAL_PATH)
|
||||
enable_punctuation = entry.data.get(CONF_ENABLE_PUNCTUATION, DEFAULT_ENABLE_PUNCTUATION)
|
||||
|
||||
# 将相对路径转换为绝对路径
|
||||
if not Path(credential_path).is_absolute():
|
||||
credential_path = hass.config.path(credential_path)
|
||||
|
||||
# 存储配置供 STT 实体使用
|
||||
hass.data[DOMAIN][entry.entry_id] = {
|
||||
CONF_CREDENTIAL_PATH: credential_path,
|
||||
CONF_ENABLE_PUNCTUATION: enable_punctuation,
|
||||
}
|
||||
|
||||
# 加载 STT 平台
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
_LOGGER.info("Doubao STT 集成已设置完成")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Config flow for Doubao Speech-to-Text integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
CONF_CREDENTIAL_PATH,
|
||||
CONF_ENABLE_PUNCTUATION,
|
||||
DEFAULT_CREDENTIAL_PATH,
|
||||
DEFAULT_ENABLE_PUNCTUATION,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
from .doubaoime_asr import ASRConfig, DoubaoASR, ASRError
|
||||
|
||||
credential_path = data.get(CONF_CREDENTIAL_PATH, DEFAULT_CREDENTIAL_PATH)
|
||||
enable_punctuation = data.get(CONF_ENABLE_PUNCTUATION, DEFAULT_ENABLE_PUNCTUATION)
|
||||
|
||||
# 将相对路径转换为 Home Assistant 配置目录下的绝对路径
|
||||
if not Path(credential_path).is_absolute():
|
||||
credential_path = hass.config.path(credential_path)
|
||||
|
||||
# 尝试初始化配置并验证凭据
|
||||
try:
|
||||
config = ASRConfig(
|
||||
credential_path=credential_path,
|
||||
enable_punctuation=enable_punctuation,
|
||||
)
|
||||
# 确保凭据已初始化(会自动注册设备如果需要)
|
||||
await config.async_ensure_credentials()
|
||||
|
||||
# 简单验证:检查是否成功获取了 device_id 和 token
|
||||
if not config.device_id or not config.token:
|
||||
raise CannotConnect("无法获取设备凭据")
|
||||
|
||||
except ASRError as err:
|
||||
_LOGGER.error("验证 Doubao STT 配置失败: %s", err)
|
||||
raise CannotConnect(str(err)) from err
|
||||
except Exception as err:
|
||||
_LOGGER.exception("验证 Doubao STT 配置时发生未知错误")
|
||||
raise CannotConnect(str(err)) from err
|
||||
|
||||
# 返回用户可读的标题信息
|
||||
return {
|
||||
"title": "Doubao STT",
|
||||
"credential_path": credential_path,
|
||||
}
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Doubao Speech-to-Text."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
info = await validate_input(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=info["title"],
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
# 显示配置表单
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_CREDENTIAL_PATH,
|
||||
default=DEFAULT_CREDENTIAL_PATH,
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_ENABLE_PUNCTUATION,
|
||||
default=DEFAULT_ENABLE_PUNCTUATION,
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: config_entries.ConfigEntry,
|
||||
) -> OptionsFlowHandler:
|
||||
"""Get the options flow for this handler."""
|
||||
return OptionsFlowHandler()
|
||||
|
||||
|
||||
class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Handle options flow for Doubao STT."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Manage the options."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
# 验证新的配置
|
||||
try:
|
||||
await validate_input(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
# 获取当前配置值
|
||||
current_credential_path = self.config_entry.data.get(
|
||||
CONF_CREDENTIAL_PATH, DEFAULT_CREDENTIAL_PATH
|
||||
)
|
||||
current_enable_punctuation = self.config_entry.data.get(
|
||||
CONF_ENABLE_PUNCTUATION, DEFAULT_ENABLE_PUNCTUATION
|
||||
)
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_CREDENTIAL_PATH,
|
||||
default=current_credential_path,
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_ENABLE_PUNCTUATION,
|
||||
default=current_enable_punctuation,
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Constants for the Doubao Speech-to-Text integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
# Integration domain
|
||||
DOMAIN: Final = "hass_stt_doubao"
|
||||
|
||||
# Configuration keys
|
||||
CONF_CREDENTIAL_PATH: Final = "credential_path"
|
||||
CONF_ENABLE_PUNCTUATION: Final = "enable_punctuation"
|
||||
|
||||
# Default values
|
||||
DEFAULT_CREDENTIAL_PATH: Final = "doubao_credentials.json"
|
||||
DEFAULT_ENABLE_PUNCTUATION: Final = True
|
||||
DEFAULT_SAMPLE_RATE: Final = 16000
|
||||
DEFAULT_CHANNELS: Final = 1
|
||||
DEFAULT_APP_NAME: Final = "com.android.chrome"
|
||||
|
||||
# Supported languages
|
||||
SUPPORTED_LANGUAGES: Final = ["zh-CN", "zh"]
|
||||
|
||||
# Service names (if needed)
|
||||
SERVICE_TRANSCRIBE: Final = "transcribe"
|
||||
|
||||
# Event types
|
||||
EVENT_DOUBAO_STT_RESULT: Final = f"{DOMAIN}_result"
|
||||
@@ -0,0 +1,23 @@
|
||||
from .asr import (
|
||||
DoubaoASR,
|
||||
ASRResponse,
|
||||
ASRError,
|
||||
ResponseType,
|
||||
AudioChunk,
|
||||
transcribe,
|
||||
transcribe_stream,
|
||||
transcribe_realtime,
|
||||
)
|
||||
from .config import ASRConfig
|
||||
|
||||
__all__ = [
|
||||
"DoubaoASR",
|
||||
"ASRConfig",
|
||||
"ASRResponse",
|
||||
"ASRError",
|
||||
"ResponseType",
|
||||
"AudioChunk",
|
||||
"transcribe",
|
||||
"transcribe_stream",
|
||||
"transcribe_realtime",
|
||||
]
|
||||
@@ -0,0 +1,75 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package asr;
|
||||
|
||||
// ASR WebSocket 请求消息
|
||||
// 用于 StartTask, StartSession, FinishSession, TaskRequest 等操作
|
||||
message AsrRequest {
|
||||
// 认证 token (从 Settings API 获取的 app_key)
|
||||
string token = 2;
|
||||
|
||||
// 服务名称,固定为 "ASR"
|
||||
string service_name = 3;
|
||||
|
||||
// 方法名称
|
||||
// - "StartTask": 启动任务
|
||||
// - "StartSession": 启动会话
|
||||
// - "FinishSession": 结束会话
|
||||
// - "TaskRequest": 发送音频数据
|
||||
string method_name = 5;
|
||||
|
||||
// JSON 格式的负载数据
|
||||
// - StartSession: 会话配置 (audio_info, enable_punctuation 等)
|
||||
// - TaskRequest: 元数据 {"extra": {}, "timestamp_ms": xxx}
|
||||
string payload = 6;
|
||||
|
||||
// 音频数据 (仅 TaskRequest 使用)
|
||||
bytes audio_data = 7;
|
||||
|
||||
// 请求 ID (UUID)
|
||||
string request_id = 8;
|
||||
|
||||
// 帧状态 (仅 TaskRequest 使用)
|
||||
FrameState frame_state = 9;
|
||||
}
|
||||
|
||||
// ASR WebSocket 响应消息
|
||||
message AsrResponse {
|
||||
// 请求 ID
|
||||
string request_id = 1;
|
||||
|
||||
// 任务 ID
|
||||
string task_id = 2;
|
||||
|
||||
// 服务名称
|
||||
string service_name = 3;
|
||||
|
||||
// 消息类型
|
||||
// - "TaskStarted": 任务已启动
|
||||
// - "SessionStarted": 会话已启动
|
||||
// - "TaskFailed": 任务失败
|
||||
// - "SessionFailed": 会话失败
|
||||
// - "SessionFinished": 会话结束
|
||||
string message_type = 4;
|
||||
|
||||
// 状态码
|
||||
int32 status_code = 5;
|
||||
|
||||
// 状态消息 (如 "OK")
|
||||
string status_message = 6;
|
||||
|
||||
// JSON 格式的识别结果
|
||||
// {"results": [...], "extra": {...}}
|
||||
string result_json = 7;
|
||||
|
||||
// 不知道是干啥的。。。
|
||||
int32 unknown_field_9 = 9;
|
||||
}
|
||||
|
||||
// 音频帧状态枚举
|
||||
enum FrameState {
|
||||
FRAME_STATE_UNSPECIFIED = 0;
|
||||
FRAME_STATE_FIRST = 1;
|
||||
FRAME_STATE_MIDDLE = 3;
|
||||
FRAME_STATE_LAST = 9;
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
import json
|
||||
from pathlib import Path
|
||||
import ssl
|
||||
import time
|
||||
from typing import AsyncIterator, Callable, List, Optional, Union
|
||||
import uuid
|
||||
from pydantic import BaseModel, Field
|
||||
import websockets
|
||||
from websockets import ClientConnection
|
||||
|
||||
from .config import ASRConfig, SessionConfig
|
||||
from .audio import AudioEncoder
|
||||
from .asr_pb2 import AsrRequest, AsrResponse as AsrResponsePb, FrameState
|
||||
|
||||
# PCM 音频数据的类型别名
|
||||
AudioChunk = bytes
|
||||
|
||||
|
||||
class ResponseType(Enum):
|
||||
"""
|
||||
ASR 响应类型
|
||||
"""
|
||||
TASK_STARTED = auto()
|
||||
SESSION_STARTED = auto()
|
||||
SESSION_FINISHED = auto()
|
||||
VAD_START = auto()
|
||||
INTERIM_RESULT = auto()
|
||||
FINAL_RESULT = auto()
|
||||
HEARTBEAT = auto()
|
||||
ERROR = auto()
|
||||
UNKNOWN = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ASRResponse:
|
||||
"""
|
||||
ASR 响应
|
||||
"""
|
||||
type: ResponseType
|
||||
text: str = ""
|
||||
is_final: bool = False
|
||||
vad_start: bool = False
|
||||
vad_finished: bool = False
|
||||
packet_number: int = -1
|
||||
error_msg: str = ""
|
||||
raw_json: Optional[dict] = None
|
||||
|
||||
|
||||
class ASRError(Exception):
|
||||
"""
|
||||
ASR 错误
|
||||
"""
|
||||
def __init__(self, message: str, response: Optional[ASRResponse] = None) -> None:
|
||||
super().__init__(message)
|
||||
self.response = response
|
||||
|
||||
|
||||
class _SessionState(BaseModel):
|
||||
"""
|
||||
ASR 会话状态
|
||||
"""
|
||||
request_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
final_text: str = ""
|
||||
is_finished: bool = False
|
||||
error: Optional[ASRResponse] = None
|
||||
|
||||
|
||||
class DoubaoASR:
|
||||
"""
|
||||
豆包输入法 ASR 客户端
|
||||
"""
|
||||
def __init__(self, config: Optional[ASRConfig] = None):
|
||||
self.config = config
|
||||
self._encoder = AudioEncoder(self.config)
|
||||
self._ssl_context: Optional[ssl.SSLContext] = None
|
||||
|
||||
async def __aenter__(self) -> DoubaoASR:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
pass
|
||||
|
||||
async def _ensure_ssl_context(self):
|
||||
"""
|
||||
确保 SSL 上下文已创建(在 executor 中运行避免阻塞)
|
||||
"""
|
||||
if self._ssl_context is None:
|
||||
loop = asyncio.get_event_loop()
|
||||
self._ssl_context = await loop.run_in_executor(None, ssl.create_default_context)
|
||||
|
||||
async def transcribe(self, audio: Union[str, Path, bytes], *, realtime = False, on_interim: Callable[[str], None] = None) -> str:
|
||||
"""
|
||||
非流式语音识别
|
||||
|
||||
:param audio: 音频文件路径或 PCM 字节数据
|
||||
:param on_interim: 可选的中间结果回调
|
||||
:return: 最终识别文本
|
||||
"""
|
||||
final_text = ""
|
||||
async for response in self.transcribe_stream(audio, realtime=realtime):
|
||||
if response.type == ResponseType.INTERIM_RESULT and on_interim:
|
||||
on_interim(response.text)
|
||||
elif response.type == ResponseType.FINAL_RESULT:
|
||||
final_text = response.text
|
||||
elif response.type == ResponseType.ERROR:
|
||||
raise ASRError(response.error_msg, response)
|
||||
return final_text
|
||||
|
||||
async def transcribe_stream(self, audio: Union[str, Path, bytes], *, realtime: bool = False) -> AsyncIterator[ASRResponse]:
|
||||
"""
|
||||
流式语音识别(完整音频)
|
||||
|
||||
:param audio: 音频文件路径或 PCM 字节数据
|
||||
:param realtime: 是否按实时速度发送
|
||||
:return: ASR 响应流,包括中间结果和最终结果
|
||||
"""
|
||||
if isinstance(audio, (str, Path)):
|
||||
pcm_data = self._encoder.convert_audio_to_pcm(
|
||||
audio, self.config.sample_rate, self.config.channels,
|
||||
)
|
||||
else:
|
||||
pcm_data = audio
|
||||
|
||||
opus_frames = await 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()
|
||||
|
||||
try:
|
||||
async with websockets.connect(
|
||||
ws_url,
|
||||
additional_headers=self.config.headers,
|
||||
open_timeout=self.config.connect_timeout,
|
||||
ssl=self._ssl_context,
|
||||
) as ws:
|
||||
# 初始化会话
|
||||
async for resp in self._initialize_session(ws, state):
|
||||
yield resp
|
||||
|
||||
# 响应队列
|
||||
response_queue: asyncio.Queue[Optional[ASRResponse]] = asyncio.Queue()
|
||||
|
||||
# 启动发送和接收任务
|
||||
send_task = asyncio.create_task(
|
||||
self._send_audio(ws, opus_frames, state, realtime)
|
||||
)
|
||||
recv_task = asyncio.create_task(
|
||||
self._receive_responses(ws, state, response_queue)
|
||||
)
|
||||
|
||||
try:
|
||||
# 从队列中获取服务器响应
|
||||
while True:
|
||||
try:
|
||||
resp = await asyncio.wait_for(
|
||||
response_queue.get(),
|
||||
timeout=self.config.recv_timeout,
|
||||
)
|
||||
if resp is None: # 结束标记
|
||||
break
|
||||
|
||||
# 心跳包仅用于重置超时,不转发给用户
|
||||
if resp.type == ResponseType.HEARTBEAT:
|
||||
continue
|
||||
|
||||
yield resp
|
||||
if resp.type == ResponseType.ERROR:
|
||||
break
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
|
||||
await send_task
|
||||
finally:
|
||||
send_task.cancel()
|
||||
recv_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await send_task
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await recv_task
|
||||
|
||||
except websockets.exceptions.WebSocketException as e:
|
||||
raise ASRError(f"WebSocket 错误: {e}") from e
|
||||
|
||||
async def transcribe_realtime(
|
||||
self,
|
||||
audio_source: AsyncIterator[AudioChunk],
|
||||
) -> AsyncIterator[ASRResponse]:
|
||||
"""
|
||||
实时流式语音识别(支持麦克风等持续音频源)
|
||||
|
||||
:param audio_source: PCM 音频数据的异步迭代器
|
||||
- 每个 chunk 应为 16-bit PCM 数据
|
||||
- 采样率和声道数应与 config 中配置一致
|
||||
- 迭代器结束时会自动发送 FinishSession
|
||||
:return: ASR 响应流
|
||||
"""
|
||||
state = _SessionState()
|
||||
|
||||
# 预创建 SSL 上下文避免阻塞
|
||||
await self._ensure_ssl_context()
|
||||
|
||||
# 获取 WebSocket URL
|
||||
ws_url = await self.config.get_ws_url()
|
||||
|
||||
try:
|
||||
async with websockets.connect(
|
||||
ws_url,
|
||||
additional_headers=self.config.headers,
|
||||
open_timeout=self.config.connect_timeout,
|
||||
ssl=self._ssl_context,
|
||||
) as ws:
|
||||
# 初始化会话
|
||||
async for resp in self._initialize_session(ws, state):
|
||||
yield resp
|
||||
|
||||
# 响应队列
|
||||
response_queue: asyncio.Queue[Optional[ASRResponse]] = asyncio.Queue()
|
||||
|
||||
# 启动发送和接收任务
|
||||
send_task = asyncio.create_task(
|
||||
self._send_audio_realtime(ws, audio_source, state)
|
||||
)
|
||||
recv_task = asyncio.create_task(
|
||||
self._receive_responses(ws, state, response_queue)
|
||||
)
|
||||
|
||||
try:
|
||||
# 实时模式不使用超时,依靠 WebSocket 层检测断开
|
||||
while True:
|
||||
resp = await response_queue.get()
|
||||
if resp is None:
|
||||
break
|
||||
|
||||
if resp.type == ResponseType.HEARTBEAT:
|
||||
continue
|
||||
|
||||
yield resp
|
||||
if resp.type == ResponseType.ERROR:
|
||||
break
|
||||
|
||||
await send_task
|
||||
finally:
|
||||
send_task.cancel()
|
||||
recv_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await send_task
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await recv_task
|
||||
|
||||
except websockets.exceptions.WebSocketException as e:
|
||||
raise ASRError(f"WebSocket 错误: {e}") from e
|
||||
|
||||
async def _send_audio_realtime(
|
||||
self,
|
||||
ws: ClientConnection,
|
||||
audio_source: AsyncIterator[AudioChunk],
|
||||
state: _SessionState,
|
||||
):
|
||||
"""
|
||||
从异步迭代器读取 PCM 数据并实时发送
|
||||
"""
|
||||
# 预先获取编码器,避免在循环中触发阻塞导入
|
||||
encoder = await self._encoder.get_encoder()
|
||||
|
||||
timestamp_ms = int(time.time() * 1000)
|
||||
frame_index = 0
|
||||
pcm_buffer = b""
|
||||
|
||||
samples_per_frame = (
|
||||
self.config.sample_rate * self.config.frame_duration_ms // 1000
|
||||
)
|
||||
bytes_per_frame = samples_per_frame * 2 # 16-bit
|
||||
|
||||
async for chunk in audio_source:
|
||||
if state.is_finished:
|
||||
break
|
||||
|
||||
pcm_buffer += chunk
|
||||
|
||||
# 当缓冲区有足够数据时,编码并发送
|
||||
while len(pcm_buffer) >= bytes_per_frame:
|
||||
pcm_frame = pcm_buffer[:bytes_per_frame]
|
||||
pcm_buffer = pcm_buffer[bytes_per_frame:]
|
||||
|
||||
# 编码为 Opus
|
||||
opus_frame = encoder.encode(pcm_frame, samples_per_frame)
|
||||
|
||||
# 确定帧状态(实时模式下不知道最后一帧,使用 FIRST/MIDDLE)
|
||||
if frame_index == 0:
|
||||
frame_state = FrameState.FRAME_STATE_FIRST
|
||||
else:
|
||||
frame_state = FrameState.FRAME_STATE_MIDDLE
|
||||
|
||||
msg = _build_asr_request(
|
||||
opus_frame,
|
||||
state.request_id,
|
||||
frame_state,
|
||||
timestamp_ms + frame_index * self.config.frame_duration_ms,
|
||||
)
|
||||
await ws.send(msg)
|
||||
frame_index += 1
|
||||
|
||||
# 迭代器结束,处理剩余数据
|
||||
if pcm_buffer and not state.is_finished:
|
||||
# 补零到完整帧
|
||||
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)
|
||||
|
||||
msg = _build_asr_request(
|
||||
opus_frame,
|
||||
state.request_id,
|
||||
FrameState.FRAME_STATE_LAST,
|
||||
timestamp_ms + frame_index * self.config.frame_duration_ms,
|
||||
)
|
||||
await ws.send(msg)
|
||||
elif frame_index > 0 and not state.is_finished:
|
||||
# 没有剩余数据,但需要发送一个 LAST 帧标记
|
||||
# 发送一个空的 LAST 帧(静音)
|
||||
silent_frame = b"\x00" * bytes_per_frame
|
||||
opus_frame = encoder.encode(silent_frame, samples_per_frame)
|
||||
|
||||
msg = _build_asr_request(
|
||||
opus_frame,
|
||||
state.request_id,
|
||||
FrameState.FRAME_STATE_LAST,
|
||||
timestamp_ms + frame_index * self.config.frame_duration_ms,
|
||||
)
|
||||
await ws.send(msg)
|
||||
|
||||
# FinishSession
|
||||
if not state.is_finished:
|
||||
token = await self.config.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()
|
||||
|
||||
# StartTask
|
||||
await ws.send(_build_start_task(state.request_id, token))
|
||||
resp = await ws.recv()
|
||||
parsed = _parse_response(resp)
|
||||
if parsed.type == ResponseType.ERROR:
|
||||
raise ASRError(f'StartTask 失败:{parsed.error_msg}', parsed)
|
||||
yield parsed
|
||||
|
||||
# StartSession
|
||||
session_config = await self.config.get_session_config()
|
||||
await ws.send(
|
||||
_build_start_session(state.request_id, token, session_config)
|
||||
)
|
||||
resp = await ws.recv()
|
||||
parsed = _parse_response(resp)
|
||||
if parsed.type == ResponseType.ERROR:
|
||||
raise ASRError(f'StartSession 失败:{parsed.error_msg}', parsed)
|
||||
yield parsed
|
||||
|
||||
async def _send_audio(
|
||||
self,
|
||||
ws: ClientConnection,
|
||||
opus_frames: List[bytes],
|
||||
state: _SessionState,
|
||||
realtime: bool,
|
||||
):
|
||||
"""
|
||||
发送音频帧
|
||||
"""
|
||||
timestamp_ms = int(time.time() * 1000)
|
||||
frame_interval = self.config.frame_duration_ms / 1000.0
|
||||
|
||||
for i, opus_frame in enumerate(opus_frames):
|
||||
if state.is_finished:
|
||||
break
|
||||
|
||||
if i == 0:
|
||||
frame_state = FrameState.FRAME_STATE_FIRST
|
||||
elif i == len(opus_frames) - 1:
|
||||
frame_state = FrameState.FRAME_STATE_LAST
|
||||
else:
|
||||
frame_state = FrameState.FRAME_STATE_MIDDLE
|
||||
|
||||
msg = _build_asr_request(
|
||||
opus_frame,
|
||||
state.request_id,
|
||||
frame_state,
|
||||
timestamp_ms + i * self.config.frame_duration_ms,
|
||||
)
|
||||
await ws.send(msg)
|
||||
|
||||
if realtime:
|
||||
await asyncio.sleep(frame_interval)
|
||||
|
||||
# FinishSession
|
||||
token = await self.config.get_token()
|
||||
await ws.send(_build_finish_session(state.request_id, token))
|
||||
|
||||
async def _receive_responses(
|
||||
self,
|
||||
ws: ClientConnection,
|
||||
state: _SessionState,
|
||||
queue: asyncio.Queue[Optional[ASRResponse]],
|
||||
):
|
||||
"""
|
||||
接受响应并放入队列
|
||||
"""
|
||||
try:
|
||||
while not state.is_finished:
|
||||
response = await ws.recv()
|
||||
resp = _parse_response(response)
|
||||
|
||||
if resp.type == ResponseType.ERROR:
|
||||
state.error = resp
|
||||
state.is_finished = True
|
||||
await queue.put(resp)
|
||||
break
|
||||
elif resp.type == ResponseType.HEARTBEAT:
|
||||
# 心跳包也放入队列,用于重置超时计时器
|
||||
await queue.put(resp)
|
||||
elif resp.type == ResponseType.SESSION_FINISHED:
|
||||
state.is_finished = True
|
||||
await queue.put(resp)
|
||||
break
|
||||
elif resp.type == ResponseType.FINAL_RESULT:
|
||||
state.final_text = resp.text
|
||||
await queue.put(resp)
|
||||
else:
|
||||
await queue.put(resp)
|
||||
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
state.is_finished = True
|
||||
finally:
|
||||
# 结束信号
|
||||
await queue.put(None)
|
||||
|
||||
|
||||
|
||||
def _build_start_task(request_id: str, token: str) -> bytes:
|
||||
"""构建 StartTask 消息 pb 数据"""
|
||||
request = AsrRequest()
|
||||
request.token = token
|
||||
request.service_name = "ASR"
|
||||
request.method_name = "StartTask"
|
||||
request.request_id = request_id
|
||||
return request.SerializeToString()
|
||||
|
||||
|
||||
def _build_start_session(request_id: str, token: str, config: SessionConfig) -> bytes:
|
||||
"""构建 StartSession 消息 pb 数据"""
|
||||
request = AsrRequest()
|
||||
request.token = token
|
||||
request.service_name = "ASR"
|
||||
request.method_name = "StartSession"
|
||||
request.request_id = request_id
|
||||
request.payload = config.model_dump_json()
|
||||
return request.SerializeToString()
|
||||
|
||||
|
||||
def _build_finish_session(request_id: str, token: str) -> bytes:
|
||||
"""构建 FinishSession 消息 pb 数据"""
|
||||
request = AsrRequest()
|
||||
request.token = token
|
||||
request.service_name = "ASR"
|
||||
request.method_name = "FinishSession"
|
||||
request.request_id = request_id
|
||||
return request.SerializeToString()
|
||||
|
||||
|
||||
def _build_asr_request(
|
||||
audio_data: bytes,
|
||||
request_id: str,
|
||||
frame_state: FrameState,
|
||||
timestamp_ms: int,
|
||||
) -> bytes:
|
||||
request = AsrRequest()
|
||||
metadata = json.dumps({"extra": {}, "timestamp_ms": timestamp_ms})
|
||||
|
||||
request.service_name = "ASR"
|
||||
request.method_name = "TaskRequest"
|
||||
request.payload = metadata
|
||||
request.audio_data = audio_data
|
||||
request.request_id = request_id
|
||||
request.frame_state = frame_state
|
||||
return request.SerializeToString()
|
||||
|
||||
|
||||
|
||||
def _parse_response(data: bytes) -> ASRResponse:
|
||||
"""解析 ASR 响应 (使用 protobuf)"""
|
||||
pb = AsrResponsePb()
|
||||
pb.ParseFromString(data)
|
||||
|
||||
message_type = pb.message_type
|
||||
result_json = pb.result_json # 字段 7: 识别结果 JSON
|
||||
status_message = pb.status_message # 字段 6: 状态消息
|
||||
|
||||
# 根据 message_type 判断响应类型
|
||||
if message_type == "TaskStarted":
|
||||
return ASRResponse(type=ResponseType.TASK_STARTED)
|
||||
|
||||
if message_type == "SessionStarted":
|
||||
return ASRResponse(type=ResponseType.SESSION_STARTED)
|
||||
|
||||
if message_type == "SessionFinished":
|
||||
return ASRResponse(type=ResponseType.SESSION_FINISHED)
|
||||
|
||||
if message_type in ("TaskFailed", "SessionFailed"):
|
||||
return ASRResponse(type=ResponseType.ERROR, error_msg=status_message)
|
||||
|
||||
# 识别结果在 result_json 字段(字段 7)
|
||||
if not result_json:
|
||||
return ASRResponse(type=ResponseType.UNKNOWN)
|
||||
|
||||
try:
|
||||
json_data = json.loads(result_json)
|
||||
except json.JSONDecodeError:
|
||||
return ASRResponse(type=ResponseType.UNKNOWN)
|
||||
|
||||
results = json_data.get("results")
|
||||
extra = json_data.get("extra", {})
|
||||
|
||||
# 无 results,可能是心跳包
|
||||
if results is None:
|
||||
return ASRResponse(
|
||||
type=ResponseType.HEARTBEAT,
|
||||
packet_number=extra.get("packet_number", -1),
|
||||
raw_json=json_data,
|
||||
)
|
||||
|
||||
# VAD 开始
|
||||
if extra.get("vad_start"):
|
||||
return ASRResponse(type=ResponseType.VAD_START, vad_start=True, raw_json=json_data)
|
||||
|
||||
# 解析识别结果
|
||||
text = ""
|
||||
is_interim = True
|
||||
vad_finished = False
|
||||
nonstream_result = False
|
||||
|
||||
for r in results:
|
||||
if r.get("text"):
|
||||
text = r.get("text")
|
||||
if r.get("is_interim") is False:
|
||||
is_interim = False
|
||||
if r.get("is_vad_finished"):
|
||||
vad_finished = True
|
||||
if r.get("extra", {}).get("nonstream_result"):
|
||||
nonstream_result = True
|
||||
|
||||
# 最终结果
|
||||
if nonstream_result or (not is_interim and vad_finished):
|
||||
return ASRResponse(
|
||||
type=ResponseType.FINAL_RESULT,
|
||||
text=text,
|
||||
is_final=True,
|
||||
vad_finished=vad_finished,
|
||||
raw_json=json_data,
|
||||
)
|
||||
|
||||
# 中间结果
|
||||
return ASRResponse(
|
||||
type=ResponseType.INTERIM_RESULT,
|
||||
text=text,
|
||||
is_final=False,
|
||||
raw_json=json_data,
|
||||
)
|
||||
|
||||
|
||||
# =============
|
||||
# 便捷函数
|
||||
# =============
|
||||
|
||||
|
||||
async def transcribe(
|
||||
audio: str | Path | bytes,
|
||||
*,
|
||||
config: ASRConfig | None = None,
|
||||
on_interim: Callable[[str], None] | None = None,
|
||||
realtime: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
便捷函数:非流式语音识别
|
||||
|
||||
Args:
|
||||
audio: 音频文件路径或 PCM 字节数据
|
||||
config: ASR 配置(可选)
|
||||
on_interim: 中间结果回调(可选)
|
||||
realtime: 是否模拟实时语音输入
|
||||
- True: 按音频实际时长发送,每帧间插入延迟,模拟实时的语音输入
|
||||
- False(默认): 尽快发送所有帧,会更快拿到结果(不知道会不会被风控)
|
||||
|
||||
Returns:
|
||||
最终识别文本
|
||||
"""
|
||||
async with DoubaoASR(config) as asr:
|
||||
return await asr.transcribe(audio, on_interim=on_interim, realtime=realtime)
|
||||
|
||||
|
||||
async def transcribe_stream(
|
||||
audio: str | Path | bytes,
|
||||
*,
|
||||
config: ASRConfig | None = None,
|
||||
realtime: bool = False,
|
||||
) -> AsyncIterator[ASRResponse]:
|
||||
"""
|
||||
便捷函数:流式语音识别(完整音频)
|
||||
|
||||
Args:
|
||||
audio: 音频文件路径或 PCM 字节数据
|
||||
config: ASR 配置(可选)
|
||||
realtime: 是否模拟实时语音输入
|
||||
- True: 按音频实际时长发送,每帧间插入延迟,模拟实时的语音输入
|
||||
- False(默认): 尽快发送所有帧,会更快拿到结果(不知道会不会被风控)
|
||||
|
||||
Yields:
|
||||
ASRResponse 对象
|
||||
"""
|
||||
async with DoubaoASR(config) as asr:
|
||||
async for resp in asr.transcribe_stream(audio, realtime=realtime):
|
||||
yield resp
|
||||
|
||||
|
||||
async def transcribe_realtime(
|
||||
audio_source: AsyncIterator[AudioChunk],
|
||||
*,
|
||||
config: ASRConfig | None = None,
|
||||
) -> AsyncIterator[ASRResponse]:
|
||||
"""
|
||||
便捷函数:实时流式语音识别(支持麦克风等持续音频源)
|
||||
|
||||
Args:
|
||||
audio_source: PCM 音频数据的异步迭代器
|
||||
- 每个 chunk 应为 16-bit PCM 数据
|
||||
- 采样率和声道数应与 config 中配置一致
|
||||
config: ASR 配置(可选)
|
||||
|
||||
Yields:
|
||||
ASRResponse 对象
|
||||
"""
|
||||
async with DoubaoASR(config) as asr:
|
||||
async for resp in asr.transcribe_realtime(audio_source):
|
||||
yield resp
|
||||
@@ -0,0 +1,40 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# NO CHECKED-IN PROTOBUF GENCODE
|
||||
# source: asr.proto
|
||||
# Protobuf Python Version: 6.31.1
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import runtime_version as _runtime_version
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
_runtime_version.ValidateProtobufRuntimeVersion(
|
||||
_runtime_version.Domain.PUBLIC,
|
||||
6,
|
||||
31,
|
||||
1,
|
||||
'',
|
||||
'asr.proto'
|
||||
)
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\tasr.proto\x12\x03\x61sr\"\xa5\x01\n\nAsrRequest\x12\r\n\x05token\x18\x02 \x01(\t\x12\x14\n\x0cservice_name\x18\x03 \x01(\t\x12\x13\n\x0bmethod_name\x18\x05 \x01(\t\x12\x0f\n\x07payload\x18\x06 \x01(\t\x12\x12\n\naudio_data\x18\x07 \x01(\x0c\x12\x12\n\nrequest_id\x18\x08 \x01(\t\x12$\n\x0b\x66rame_state\x18\t \x01(\x0e\x32\x0f.asr.FrameState\"\xb9\x01\n\x0b\x41srResponse\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x0f\n\x07task_id\x18\x02 \x01(\t\x12\x14\n\x0cservice_name\x18\x03 \x01(\t\x12\x14\n\x0cmessage_type\x18\x04 \x01(\t\x12\x13\n\x0bstatus_code\x18\x05 \x01(\x05\x12\x16\n\x0estatus_message\x18\x06 \x01(\t\x12\x13\n\x0bresult_json\x18\x07 \x01(\t\x12\x17\n\x0funknown_field_9\x18\t \x01(\x05*n\n\nFrameState\x12\x1b\n\x17\x46RAME_STATE_UNSPECIFIED\x10\x00\x12\x15\n\x11\x46RAME_STATE_FIRST\x10\x01\x12\x16\n\x12\x46RAME_STATE_MIDDLE\x10\x03\x12\x14\n\x10\x46RAME_STATE_LAST\x10\tb\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'asr_pb2', _globals)
|
||||
if not _descriptor._USE_C_DESCRIPTORS:
|
||||
DESCRIPTOR._loaded_options = None
|
||||
_globals['_FRAMESTATE']._serialized_start=374
|
||||
_globals['_FRAMESTATE']._serialized_end=484
|
||||
_globals['_ASRREQUEST']._serialized_start=19
|
||||
_globals['_ASRREQUEST']._serialized_end=184
|
||||
_globals['_ASRRESPONSE']._serialized_start=187
|
||||
_globals['_ASRRESPONSE']._serialized_end=372
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
@@ -0,0 +1,55 @@
|
||||
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import message as _message
|
||||
from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
|
||||
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
|
||||
class FrameState(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||
__slots__ = ()
|
||||
FRAME_STATE_UNSPECIFIED: _ClassVar[FrameState]
|
||||
FRAME_STATE_FIRST: _ClassVar[FrameState]
|
||||
FRAME_STATE_MIDDLE: _ClassVar[FrameState]
|
||||
FRAME_STATE_LAST: _ClassVar[FrameState]
|
||||
FRAME_STATE_UNSPECIFIED: FrameState
|
||||
FRAME_STATE_FIRST: FrameState
|
||||
FRAME_STATE_MIDDLE: FrameState
|
||||
FRAME_STATE_LAST: FrameState
|
||||
|
||||
class AsrRequest(_message.Message):
|
||||
__slots__ = ("token", "service_name", "method_name", "payload", "audio_data", "request_id", "frame_state")
|
||||
TOKEN_FIELD_NUMBER: _ClassVar[int]
|
||||
SERVICE_NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
METHOD_NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
PAYLOAD_FIELD_NUMBER: _ClassVar[int]
|
||||
AUDIO_DATA_FIELD_NUMBER: _ClassVar[int]
|
||||
REQUEST_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
FRAME_STATE_FIELD_NUMBER: _ClassVar[int]
|
||||
token: str
|
||||
service_name: str
|
||||
method_name: str
|
||||
payload: str
|
||||
audio_data: bytes
|
||||
request_id: str
|
||||
frame_state: FrameState
|
||||
def __init__(self, token: _Optional[str] = ..., service_name: _Optional[str] = ..., method_name: _Optional[str] = ..., payload: _Optional[str] = ..., audio_data: _Optional[bytes] = ..., request_id: _Optional[str] = ..., frame_state: _Optional[_Union[FrameState, str]] = ...) -> None: ...
|
||||
|
||||
class AsrResponse(_message.Message):
|
||||
__slots__ = ("request_id", "task_id", "service_name", "message_type", "status_code", "status_message", "result_json", "unknown_field_9")
|
||||
REQUEST_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
TASK_ID_FIELD_NUMBER: _ClassVar[int]
|
||||
SERVICE_NAME_FIELD_NUMBER: _ClassVar[int]
|
||||
MESSAGE_TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||
STATUS_CODE_FIELD_NUMBER: _ClassVar[int]
|
||||
STATUS_MESSAGE_FIELD_NUMBER: _ClassVar[int]
|
||||
RESULT_JSON_FIELD_NUMBER: _ClassVar[int]
|
||||
UNKNOWN_FIELD_9_FIELD_NUMBER: _ClassVar[int]
|
||||
request_id: str
|
||||
task_id: str
|
||||
service_name: str
|
||||
message_type: str
|
||||
status_code: int
|
||||
status_message: str
|
||||
result_json: str
|
||||
unknown_field_9: int
|
||||
def __init__(self, request_id: _Optional[str] = ..., task_id: _Optional[str] = ..., service_name: _Optional[str] = ..., message_type: _Optional[str] = ..., status_code: _Optional[int] = ..., status_message: _Optional[str] = ..., result_json: _Optional[str] = ..., unknown_field_9: _Optional[int] = ...) -> None: ...
|
||||
@@ -0,0 +1,77 @@
|
||||
from typing import Optional, List, Union, TYPE_CHECKING
|
||||
from pathlib import Path
|
||||
import asyncio
|
||||
|
||||
import miniaudio
|
||||
|
||||
from .config import ASRConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import opuslib
|
||||
|
||||
|
||||
class AudioEncoder:
|
||||
"""
|
||||
进行音频格式转换
|
||||
"""
|
||||
def __init__(self, config: ASRConfig) -> None:
|
||||
self.config = config
|
||||
self._encoder: Optional["opuslib.Encoder"] = None
|
||||
self._opuslib_module = 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":
|
||||
"""异步获取编码器"""
|
||||
if self._encoder is None:
|
||||
await self._ensure_opuslib()
|
||||
self._encoder = self._opuslib_module.Encoder(
|
||||
self.config.sample_rate,
|
||||
self.config.channels,
|
||||
self._opuslib_module.APPLICATION_AUDIO,
|
||||
)
|
||||
return self._encoder
|
||||
|
||||
async def pcm_to_opus_frames(self, pcm_data: bytes) -> List[bytes]:
|
||||
"""将 PCM 数据转换为 Opus 帧(异步)"""
|
||||
encoder = await self.get_encoder()
|
||||
|
||||
samples_per_frame = (
|
||||
self.config.sample_rate * self.config.frame_duration_ms // 1000
|
||||
)
|
||||
bytes_per_frame = samples_per_frame * 2 # 16-bit
|
||||
|
||||
frames = []
|
||||
for i in range(0, len(pcm_data), bytes_per_frame):
|
||||
chunk = pcm_data[i : i + bytes_per_frame]
|
||||
if len(chunk) < bytes_per_frame:
|
||||
chunk = chunk + b"\x00" * (bytes_per_frame - len(chunk))
|
||||
|
||||
opus_frame = encoder.encode(chunk, samples_per_frame)
|
||||
frames.append(opus_frame)
|
||||
|
||||
return frames
|
||||
|
||||
@staticmethod
|
||||
def convert_audio_to_pcm(
|
||||
audio_path: Union[Path, str],
|
||||
sample_rate: int = 16000,
|
||||
channels: int = 1,
|
||||
) -> bytes:
|
||||
decoded = miniaudio.decode_file(
|
||||
str(audio_path),
|
||||
output_format=miniaudio.SampleFormat.SIGNED16,
|
||||
nchannels=channels,
|
||||
sample_rate=sample_rate,
|
||||
)
|
||||
return decoded.samples.tobytes()
|
||||
@@ -0,0 +1,231 @@
|
||||
from dataclasses import dataclass, field
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
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
|
||||
|
||||
|
||||
class _AudioInfo(BaseModel):
|
||||
channel: int
|
||||
format: str
|
||||
sample_rate: int
|
||||
|
||||
|
||||
class _SessionExtraConfig(BaseModel):
|
||||
app_name: str
|
||||
cell_compress_rate: int
|
||||
did: str
|
||||
enable_asr_threepass: bool
|
||||
enable_asr_twopass: bool
|
||||
input_mode: str
|
||||
|
||||
|
||||
class SessionConfig(BaseModel):
|
||||
"""
|
||||
ASR 任务开始前需要初始化 Session 的配置
|
||||
"""
|
||||
audio_info: _AudioInfo
|
||||
enable_punctuation: bool
|
||||
enable_speech_rejection: bool
|
||||
extra: _SessionExtraConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class ASRConfig:
|
||||
"""
|
||||
ASR 配置
|
||||
|
||||
如果不提供 device_id 和 token,将自动注册设备并获取 token。
|
||||
|
||||
示例:
|
||||
# 自动获取凭据(首次使用时会注册设备,不持久化)
|
||||
config = ASRConfig()
|
||||
|
||||
# 使用已有凭据
|
||||
config = ASRConfig(device_id="1234567890123456", token="MyToken123")
|
||||
|
||||
# 使用凭据文件(推荐,首次注册后自动缓存)
|
||||
config = ASRConfig(credential_path="~/.config/doubao-asr/credentials.json")
|
||||
|
||||
# 凭据文件 + 覆盖部分参数
|
||||
config = ASRConfig(
|
||||
credential_path="~/.config/doubao-asr/credentials.json",
|
||||
token="NewToken", # 覆盖文件中的 token
|
||||
)
|
||||
"""
|
||||
url: str = WEBSOCKET_URL
|
||||
aid: str = AID
|
||||
user_agent: str = USER_AGENT
|
||||
|
||||
device_id: Optional[str] = None # 空则自动获取
|
||||
token: Optional[str] = None # 空则自动获取
|
||||
credential_path: Union[str, Path, None] = None
|
||||
"""
|
||||
凭据文件路径
|
||||
"""
|
||||
|
||||
# 这些都是客户端给的默认值,挺通用的。其实我也没尝试过改了服务器会不会认
|
||||
# 音频配置
|
||||
sample_rate: int = 16000
|
||||
channels: int = 1
|
||||
frame_duration_ms: int = 20
|
||||
|
||||
# 会话配置
|
||||
enable_punctuation: bool = True
|
||||
enable_speech_rejection: bool = False
|
||||
enable_asr_twopass: bool = True
|
||||
enable_asr_threepass: bool = True
|
||||
# 这里是输入法当前作用在哪个应用上
|
||||
# 可能服务器会根据当前所使用的程序调整不同的语音识别策略??
|
||||
# 这里用 Chrome 浏览器,算是比较通用的了吧?
|
||||
app_name: str = "com.android.chrome"
|
||||
|
||||
# 连接配置
|
||||
connect_timeout: float = 10.0
|
||||
recv_timeout: float = 10.0
|
||||
|
||||
# 内部状态
|
||||
_credentials: Optional[DeviceCredentials] = field(default=None, repr=None)
|
||||
_initialized: bool = field(default=False, repr=False)
|
||||
|
||||
async def _load_credentials_from_file(self) -> Optional[DeviceCredentials]:
|
||||
"""
|
||||
从缓存文件中加载凭据信息(异步)
|
||||
"""
|
||||
if self.credential_path is None:
|
||||
return None
|
||||
|
||||
path = Path(self.credential_path).expanduser()
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
async with aiofiles.open(path, 'r', encoding='utf-8') as f:
|
||||
content = await f.read()
|
||||
data = json.loads(content)
|
||||
return DeviceCredentials(**data)
|
||||
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
async def _save_credentials_to_file(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))
|
||||
|
||||
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)
|
||||
|
||||
async def async_ensure_credentials(self):
|
||||
"""
|
||||
确保凭据已初始化(异步)
|
||||
|
||||
优先级:
|
||||
1. 直接传入的 device_id/token 参数(最高优先级)
|
||||
2. credential_path 文件中的值
|
||||
3. 自动注册获取(最低优先级)
|
||||
|
||||
如果指定了 credential_path 且文件不存在,会注册设备并保存到文件。
|
||||
"""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
# 保存直接通过参数传入的凭据,用于进行覆盖
|
||||
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
|
||||
# 使用文件中的值作为默认
|
||||
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:
|
||||
# 在 executor 中运行同步的 register_device
|
||||
loop = asyncio.get_event_loop()
|
||||
self._credentials = await loop.run_in_executor(None, 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
|
||||
# 在 executor 中运行同步的 get_asr_token
|
||||
loop = asyncio.get_event_loop()
|
||||
self.token = await loop.run_in_executor(None, get_asr_token, self.device_id, cdid)
|
||||
|
||||
# 如果指定了 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)
|
||||
|
||||
# 覆盖用户传入的参数
|
||||
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 get_ws_url(self) -> str:
|
||||
"""获取 WebSocket URL(异步)"""
|
||||
await self.async_ensure_credentials()
|
||||
return f'{self.url}?aid={self.aid}&device_id={self.device_id}'
|
||||
|
||||
@property
|
||||
def headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"User-Agent": self.user_agent,
|
||||
"proto-version": "v2",
|
||||
"x-custom-keepalive": "true"
|
||||
}
|
||||
|
||||
async def get_session_config(self) -> SessionConfig:
|
||||
"""获取会话配置(异步)"""
|
||||
await self.async_ensure_credentials()
|
||||
audio_info = _AudioInfo(
|
||||
channel=self.channels,
|
||||
format="speech_opus",
|
||||
sample_rate=self.sample_rate,
|
||||
)
|
||||
extra = _SessionExtraConfig(
|
||||
app_name=self.app_name,
|
||||
cell_compress_rate=8,
|
||||
did=self.device_id,
|
||||
enable_asr_threepass=self.enable_asr_threepass,
|
||||
enable_asr_twopass=self.enable_asr_twopass,
|
||||
input_mode="tool",
|
||||
)
|
||||
|
||||
return SessionConfig(
|
||||
audio_info=audio_info,
|
||||
enable_punctuation=self.enable_punctuation,
|
||||
enable_speech_rejection=self.enable_speech_rejection,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
async def get_token(self) -> str:
|
||||
"""获取 token(异步)"""
|
||||
await self.async_ensure_credentials()
|
||||
return self.token
|
||||
@@ -0,0 +1,43 @@
|
||||
# 设备注册 API URL
|
||||
REGISTER_URL = "https://log.snssdk.com/service/2/device_register/"
|
||||
|
||||
# Settings API URL (获取 Token)
|
||||
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"
|
||||
|
||||
# 豆包输入法的 APP ID
|
||||
AID = 401734
|
||||
|
||||
# 应用配置 (豆包输入法)
|
||||
APP_CONFIG = {
|
||||
"aid": AID,
|
||||
"app_name": "oime",
|
||||
"version_code": 100102018,
|
||||
"version_name": "1.1.2",
|
||||
"manifest_version_code": 100102018,
|
||||
"update_version_code": 100102018,
|
||||
"channel": "official",
|
||||
"package": "com.bytedance.android.doubaoime",
|
||||
}
|
||||
|
||||
# 默认设备配置 (模拟 Pixel 7 Pro)
|
||||
DEFAULT_DEVICE_CONFIG = {
|
||||
"device_platform": "android",
|
||||
"os": "android",
|
||||
"os_api": "34",
|
||||
"os_version": "16",
|
||||
"device_type": "Pixel 7 Pro",
|
||||
"device_brand": "google",
|
||||
"device_model": "Pixel 7 Pro",
|
||||
"resolution": "1080*2400",
|
||||
"dpi": "420",
|
||||
"language": "zh",
|
||||
"timezone": 8,
|
||||
"access": "wifi",
|
||||
"rom": "UP1A.231005.007",
|
||||
"rom_version": "UP1A.231005.007",
|
||||
}
|
||||
|
||||
USER_AGENT = "com.bytedance.android.doubaoime/100102018 (Linux; U; Android 16; en_US; Pixel 7 Pro; Build/BP2A.250605.031.A2; Cronet/TTNetVersion:94cf429a 2025-11-17 QuicVersion:1f89f732 2025-05-08)"
|
||||
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
设备初始化相关
|
||||
|
||||
需要先根据客户端配置在豆包服务器注册设备,获取 device_id, install_id, token 等信息
|
||||
"""
|
||||
import hashlib
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Optional
|
||||
|
||||
import secrets
|
||||
import requests
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from .constants import APP_CONFIG, DEFAULT_DEVICE_CONFIG, USER_AGENT, REGISTER_URL, SETTINGS_URL
|
||||
|
||||
|
||||
class DeviceCredentials(BaseModel):
|
||||
"""
|
||||
设备凭据,用于缓存设备信息
|
||||
主要是 `device_id` 和 `token`. 其他貌似不影响
|
||||
"""
|
||||
device_id: Optional[str] = None
|
||||
install_id: Optional[str] = None
|
||||
cdid: Optional[str] = None
|
||||
openudid: Optional[str] = None
|
||||
clientudid: Optional[str] = None
|
||||
token: Optional[str] = ""
|
||||
"""
|
||||
用于 ASR 的 token
|
||||
"""
|
||||
|
||||
|
||||
class DeviceRegisterHeaderField(BaseModel):
|
||||
"""
|
||||
设备注册接口用到的请求体 header 字段
|
||||
"""
|
||||
# 设备标识(注册时为 0,注册后更新)
|
||||
device_id: int = 0
|
||||
install_id: int = 0
|
||||
|
||||
# 应用配置
|
||||
aid: int
|
||||
"""app id,固定值"""
|
||||
app_name: str
|
||||
version_code: int
|
||||
version_name: str
|
||||
manifest_version_code: int
|
||||
update_version_code: int
|
||||
channel: str
|
||||
package: str
|
||||
|
||||
# 设备平台信息
|
||||
device_platform: str
|
||||
os: str
|
||||
os_api: str
|
||||
os_version: str
|
||||
device_type: str
|
||||
device_brand: str
|
||||
device_model: str
|
||||
resolution: str
|
||||
dpi: str
|
||||
language: str
|
||||
timezone: int
|
||||
access: str
|
||||
rom: str
|
||||
rom_version: str
|
||||
|
||||
# 设备唯一标识
|
||||
openudid: str
|
||||
clientudid: str
|
||||
cdid: str
|
||||
|
||||
# 地区与时区
|
||||
region: str = "CN"
|
||||
tz_name: str = "Asia/Shanghai"
|
||||
tz_offset: int = 28800
|
||||
sim_region: str = "cn"
|
||||
carrier_region: str = "cn"
|
||||
|
||||
# 其他设备信息
|
||||
cpu_abi: str = "arm64-v8a"
|
||||
build_serial: str = "unknown"
|
||||
not_request_sender: int = 0
|
||||
sig_hash: str = ""
|
||||
google_aid: str = ""
|
||||
mc: str = ""
|
||||
serial_number: str = ""
|
||||
|
||||
@classmethod
|
||||
def default(cls, cdid: Optional[str] = None, openudid: Optional[str] = None, clientudid: Optional[str] = None) -> "DeviceRegisterHeaderField":
|
||||
"""
|
||||
使用默认配置构建设备注册 Header
|
||||
"""
|
||||
|
||||
return cls(
|
||||
**APP_CONFIG,
|
||||
**DEFAULT_DEVICE_CONFIG,
|
||||
cdid=cdid or _generate_cdid(),
|
||||
openudid=openudid or _generate_openudid(),
|
||||
clientudid=clientudid or _generate_clientudid(),
|
||||
)
|
||||
|
||||
|
||||
class DeviceRegisterBody(BaseModel):
|
||||
"""
|
||||
设备注册接口用到的完整的请求体
|
||||
"""
|
||||
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
||||
|
||||
magic_tag: str = "ss_app_log"
|
||||
header: DeviceRegisterHeaderField
|
||||
gen_time: int = Field(default_factory=lambda: int(time.time() * 1000), serialization_alias="_gen_time")
|
||||
|
||||
@classmethod
|
||||
def new(cls, header: DeviceRegisterHeaderField):
|
||||
return cls(header=header)
|
||||
|
||||
|
||||
class DeviceRegisterParams(BaseModel):
|
||||
"""
|
||||
设备注册接口的 URL Params
|
||||
"""
|
||||
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
||||
|
||||
device_platform: str
|
||||
os: str
|
||||
ssmix: str = "a"
|
||||
rticket: int = Field(default_factory=lambda: 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"
|
||||
|
||||
@classmethod
|
||||
def default(cls, cdid: str) -> "DeviceRegisterParams":
|
||||
"""
|
||||
使用默认配置构建 URL 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 DeviceRegisterResponse(BaseModel):
|
||||
server_time: int
|
||||
device_id: int
|
||||
install_id: int
|
||||
new_user: Optional[int] = None
|
||||
device_id_str: Optional[str] = None
|
||||
install_id_str: Optional[str] = None
|
||||
ssid: Optional[str] = None
|
||||
device_token: Optional[str] = None
|
||||
|
||||
|
||||
class SettingsParams(BaseModel):
|
||||
"""
|
||||
Settings API 的 URL Params(用于获取 ASR token)
|
||||
"""
|
||||
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
|
||||
device_id: str
|
||||
|
||||
@classmethod
|
||||
def default(cls, device_id: str, cdid: str) -> "SettingsParams":
|
||||
"""
|
||||
使用默认配置构建 Settings Params
|
||||
"""
|
||||
return cls(
|
||||
cdid=cdid,
|
||||
device_id=device_id,
|
||||
channel=APP_CONFIG["channel"],
|
||||
aid=str(APP_CONFIG["aid"]),
|
||||
app_name=APP_CONFIG["app_name"],
|
||||
version_code=str(APP_CONFIG["version_code"]),
|
||||
version_name=APP_CONFIG["version_name"],
|
||||
)
|
||||
|
||||
|
||||
class _AsrConfig(BaseModel):
|
||||
"""ASR 配置"""
|
||||
app_key: str
|
||||
|
||||
|
||||
class _Settings(BaseModel):
|
||||
"""Settings 配置"""
|
||||
asr_config: _AsrConfig
|
||||
|
||||
|
||||
class _SettingsData(BaseModel):
|
||||
"""Settings 数据"""
|
||||
settings: _Settings
|
||||
|
||||
|
||||
class SettingsResponse(BaseModel):
|
||||
"""Settings API 响应"""
|
||||
data: _SettingsData
|
||||
message: str
|
||||
|
||||
@property
|
||||
def app_key(self) -> str:
|
||||
"""获取 ASR app_key (token)"""
|
||||
return self.data.settings.asr_config.app_key
|
||||
|
||||
|
||||
def _generate_openudid() -> str:
|
||||
return secrets.token_hex(8)
|
||||
|
||||
|
||||
def _generate_cdid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def _generate_clientudid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def register_device() -> DeviceCredentials:
|
||||
"""
|
||||
首次使用,注册设备获取 device_id
|
||||
"""
|
||||
cdid = _generate_cdid()
|
||||
openudid = _generate_openudid()
|
||||
clientudid = _generate_clientudid()
|
||||
|
||||
header = DeviceRegisterHeaderField.default(cdid=cdid, openudid=openudid, clientudid=clientudid)
|
||||
body = DeviceRegisterBody.new(header)
|
||||
params = DeviceRegisterParams.default(cdid)
|
||||
|
||||
headers = {
|
||||
"User-Agent": USER_AGENT,
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
REGISTER_URL,
|
||||
params=params.model_dump(),
|
||||
json=body.model_dump(),
|
||||
headers=headers,
|
||||
verify=False, # 禁用SSL证书验证以兼容某些环境
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
response_data = DeviceRegisterResponse(**response_json)
|
||||
|
||||
if response_data.device_id and response_data.device_id != 0:
|
||||
return DeviceCredentials(
|
||||
device_id=str(response_data.device_id),
|
||||
install_id=str(response_data.install_id),
|
||||
cdid=cdid,
|
||||
openudid=openudid,
|
||||
clientudid=clientudid,
|
||||
)
|
||||
|
||||
|
||||
def get_asr_token(device_id: str, cdid: str) -> str:
|
||||
"""
|
||||
获取 ASR 请求所需的 token
|
||||
"""
|
||||
if cdid is None:
|
||||
cdid = _generate_cdid()
|
||||
|
||||
params = SettingsParams.default(device_id, cdid)
|
||||
body_str = "body=null"
|
||||
x_ss_stub = hashlib.md5(body_str.encode()).hexdigest().upper()
|
||||
|
||||
headers = {
|
||||
"User-Agent": USER_AGENT,
|
||||
"x-ss-stub": x_ss_stub,
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
SETTINGS_URL,
|
||||
params=params,
|
||||
data=body_str,
|
||||
headers=headers,
|
||||
verify=False, # 禁用SSL证书验证以兼容某些环境
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
response_data = SettingsResponse(**response_json)
|
||||
|
||||
return response_data.app_key
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"domain": "hass_stt_doubao",
|
||||
"name": "Doubao Speech to Text",
|
||||
"version": "1.0.0",
|
||||
"documentation": "https://github.com/yourusername/hass_stt_doubao",
|
||||
"issue_tracker": "https://github.com/yourusername/hass_stt_doubao/issues",
|
||||
"dependencies": [],
|
||||
"codeowners": ["@xyzmos"],
|
||||
"requirements": [
|
||||
"miniaudio>=1.61",
|
||||
"opuslib>=3.0.1",
|
||||
"protobuf>=3.20.0",
|
||||
"pydantic>=2.0.0",
|
||||
"websockets>=12.0"
|
||||
],
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
aiofiles>=23.0.0
|
||||
miniaudio>=1.61
|
||||
opuslib>=3.0.1
|
||||
protobuf>=3.20.0
|
||||
pydantic>=2.0.0
|
||||
websockets>=12.0
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "配置 Doubao 语音识别",
|
||||
"description": "设置 Doubao STT 集成",
|
||||
"data": {
|
||||
"credential_path": "凭据文件路径",
|
||||
"enable_punctuation": "启用标点符号"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "无法连接到 Doubao 服务,请检查网络连接或凭据配置",
|
||||
"unknown": "发生未知错误"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "该设备已经配置过了"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Doubao STT 选项",
|
||||
"description": "修改 Doubao STT 配置选项",
|
||||
"data": {
|
||||
"credential_path": "凭据文件路径",
|
||||
"enable_punctuation": "启用标点符号"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Support for Doubao Speech-to-Text service."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import AsyncIterable
|
||||
|
||||
from homeassistant.components.stt import (
|
||||
AudioBitRates,
|
||||
AudioChannels,
|
||||
AudioCodecs,
|
||||
AudioFormats,
|
||||
AudioSampleRates,
|
||||
SpeechMetadata,
|
||||
SpeechResult,
|
||||
SpeechResultState,
|
||||
SpeechToTextEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
CONF_CREDENTIAL_PATH,
|
||||
CONF_ENABLE_PUNCTUATION,
|
||||
SUPPORTED_LANGUAGES,
|
||||
)
|
||||
from .doubaoime_asr import ASRConfig, ASRError, DoubaoASR, ResponseType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Doubao STT from a config entry."""
|
||||
config_data = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
async_add_entities(
|
||||
[DoubaoSTTEntity(config_entry, config_data)],
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
class DoubaoSTTEntity(SpeechToTextEntity):
|
||||
"""Doubao Speech-to-Text entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_entry: ConfigEntry,
|
||||
config_data: dict,
|
||||
) -> None:
|
||||
"""Initialize Doubao STT entity."""
|
||||
self._config_entry = config_entry
|
||||
self._credential_path = config_data[CONF_CREDENTIAL_PATH]
|
||||
self._enable_punctuation = config_data[CONF_ENABLE_PUNCTUATION]
|
||||
self._attr_name = "Doubao STT"
|
||||
self._attr_unique_id = f"{config_entry.entry_id}_stt"
|
||||
|
||||
@property
|
||||
def supported_languages(self) -> list[str]:
|
||||
"""Return a list of supported languages."""
|
||||
return SUPPORTED_LANGUAGES
|
||||
|
||||
@property
|
||||
def supported_formats(self) -> list[AudioFormats]:
|
||||
"""Return a list of supported formats."""
|
||||
return [AudioFormats.WAV]
|
||||
|
||||
@property
|
||||
def supported_codecs(self) -> list[AudioCodecs]:
|
||||
"""Return a list of supported codecs."""
|
||||
return [AudioCodecs.PCM]
|
||||
|
||||
@property
|
||||
def supported_bit_rates(self) -> list[AudioBitRates]:
|
||||
"""Return a list of supported bit rates."""
|
||||
return [AudioBitRates.BITRATE_16]
|
||||
|
||||
@property
|
||||
def supported_sample_rates(self) -> list[AudioSampleRates]:
|
||||
"""Return a list of supported sample rates."""
|
||||
return [AudioSampleRates.SAMPLERATE_16000]
|
||||
|
||||
@property
|
||||
def supported_channels(self) -> list[AudioChannels]:
|
||||
"""Return a list of supported channels."""
|
||||
return [AudioChannels.CHANNEL_MONO]
|
||||
|
||||
async def async_process_audio_stream(
|
||||
self, metadata: SpeechMetadata, stream: AsyncIterable[bytes]
|
||||
) -> SpeechResult:
|
||||
"""Process an audio stream to STT service.
|
||||
|
||||
Args:
|
||||
metadata: Metadata about the audio stream
|
||||
stream: Async iterable of audio chunks (PCM 16-bit, 16kHz, mono)
|
||||
|
||||
Returns:
|
||||
SpeechResult with the transcribed text
|
||||
"""
|
||||
_LOGGER.debug(
|
||||
"开始处理音频流: language=%s, format=%s, codec=%s, sample_rate=%s",
|
||||
metadata.language,
|
||||
metadata.format,
|
||||
metadata.codec,
|
||||
metadata.sample_rate,
|
||||
)
|
||||
|
||||
# 创建 ASR 配置
|
||||
config = ASRConfig(
|
||||
credential_path=self._credential_path,
|
||||
enable_punctuation=self._enable_punctuation,
|
||||
sample_rate=16000, # HA 固定使用 16kHz
|
||||
channels=1, # HA 固定使用单声道
|
||||
)
|
||||
|
||||
try:
|
||||
# 使用 DoubaoASR 进行实时识别
|
||||
final_text = ""
|
||||
async with DoubaoASR(config) as asr:
|
||||
async for response in asr.transcribe_realtime(stream):
|
||||
if response.type == ResponseType.FINAL_RESULT:
|
||||
final_text = response.text
|
||||
_LOGGER.debug("收到最终识别结果: %s", final_text)
|
||||
elif response.type == ResponseType.INTERIM_RESULT:
|
||||
_LOGGER.debug("收到中间识别结果: %s", response.text)
|
||||
elif response.type == ResponseType.ERROR:
|
||||
_LOGGER.error("识别过程出错: %s", response.error_msg)
|
||||
return SpeechResult(
|
||||
text=None,
|
||||
result=SpeechResultState.ERROR,
|
||||
)
|
||||
|
||||
if not final_text:
|
||||
_LOGGER.warning("识别完成但未获得最终结果")
|
||||
return SpeechResult(
|
||||
text=None,
|
||||
result=SpeechResultState.ERROR,
|
||||
)
|
||||
|
||||
_LOGGER.info("语音识别成功: %s", final_text)
|
||||
return SpeechResult(
|
||||
text=final_text,
|
||||
result=SpeechResultState.SUCCESS,
|
||||
)
|
||||
|
||||
except ASRError as err:
|
||||
_LOGGER.error("Doubao ASR 识别失败: %s", err)
|
||||
return SpeechResult(
|
||||
text=None,
|
||||
result=SpeechResultState.ERROR,
|
||||
)
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
_LOGGER.exception("处理音频流时发生未知错误")
|
||||
return SpeechResult(
|
||||
text=None,
|
||||
result=SpeechResultState.ERROR,
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "配置 Doubao 语音识别",
|
||||
"description": "设置 Doubao STT 集成",
|
||||
"data": {
|
||||
"credential_path": "凭据文件路径",
|
||||
"enable_punctuation": "启用标点符号"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "无法连接到 Doubao 服务,请检查网络连接或凭据配置",
|
||||
"unknown": "发生未知错误"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "该设备已经配置过了"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Doubao STT 选项",
|
||||
"description": "修改 Doubao STT 配置选项",
|
||||
"data": {
|
||||
"credential_path": "凭据文件路径",
|
||||
"enable_punctuation": "启用标点符号"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user