From 1e459a315c28bb3851e1d8cce9bcb558f47422c9 Mon Sep 17 00:00:00 2001 From: xyzm Date: Sat, 31 Jan 2026 20:57:20 +0800 Subject: [PATCH] first commit --- .github/workflows/validate.yml | 29 ++ .gitignore | 3 + LICENSE | 201 ++++++++++ README.md | 60 +++ custom_components/hass_stt_qwen/__init__.py | 25 ++ .../hass_stt_qwen/config_flow.py | 176 +++++++++ custom_components/hass_stt_qwen/const.py | 48 +++ custom_components/hass_stt_qwen/manifest.json | 10 + .../hass_stt_qwen/qwen3_asr_client.py | 372 ++++++++++++++++++ custom_components/hass_stt_qwen/stt.py | 181 +++++++++ .../hass_stt_qwen/translations/en.json | 36 ++ .../hass_stt_qwen/translations/zh-Hans.json | 36 ++ hacs.json | 5 + 13 files changed, 1182 insertions(+) create mode 100644 .github/workflows/validate.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 custom_components/hass_stt_qwen/__init__.py create mode 100644 custom_components/hass_stt_qwen/config_flow.py create mode 100644 custom_components/hass_stt_qwen/const.py create mode 100644 custom_components/hass_stt_qwen/manifest.json create mode 100644 custom_components/hass_stt_qwen/qwen3_asr_client.py create mode 100644 custom_components/hass_stt_qwen/stt.py create mode 100644 custom_components/hass_stt_qwen/translations/en.json create mode 100644 custom_components/hass_stt_qwen/translations/zh-Hans.json create mode 100644 hacs.json diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..2b47432 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,29 @@ +name: "Validation And Formatting" +on: + push: + pull_request: + workflow_dispatch: + schedule: + - cron: '0 0 * * *' +jobs: + hacs: + runs-on: "ubuntu-latest" + name: HACS + steps: + - name: Check out the repository + uses: "actions/checkout@v3" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + + hassfest: + runs-on: "ubuntu-latest" + name: Hassfest + steps: + - name: Check out the repository + uses: "actions/checkout@v3" + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b599c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea/ +*.mp3 +*.pyc \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f29b86 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# Home Assistant 阿里云通义千问 STT 语音识别插件 + +本插件集成了阿里云通义千问 (Qwen) 实时语音识别服务,为 Home Assistant 提供高精度的语音转文字功能。 + +## 功能特性 + +- **模型支持**:支持 `qwen3-asr-flash-realtime`(通义千问实时识别)。 +- **图形化配置**:支持通过 Home Assistant 前端界面直接添加集成。 +- **多语言支持**:支持中文、英文、粤语等多种语言识别。 +- **实时识别**:基于 WebSocket 协议,低延迟实时转写。 +- **多区域支持**:支持北京(国内)和新加坡(国际)节点。 +- **音频格式**:WAV(PCM 16-bit / 16kHz / 单声道)。 + +## 安装说明 + +### 方法一:手动安装 + +1. 下载本项目代码。 +2. 将 `hass_stt_qwen` 文件夹复制到您的 Home Assistant 配置目录下的 `custom_components` 文件夹中。 + - 最终路径应为:`/config/custom_components/hass_stt_qwen/` +3. 重启 Home Assistant。 + +## 配置指南 + +1. 登录 Home Assistant。 +2. 点击左侧菜单的 **配置** -> **设备与服务**。 +3. 点击右下角的 **添加集成** 按钮。 +4. 搜索 **阿里通义千问语音识别** 并点击。 +5. 在弹出的配置窗口中输入以下信息: + - **API 密钥**: 您的阿里云 DashScope API 密钥。 + - 获取方式:[阿里云百炼控制台](https://bailian.console.aliyun.com/) + - **模型**: 选择您需要使用的模型(推荐 `qwen3-asr-flash-realtime`)。 + - **区域**: 根据您的 API Key 所在区域选择(北京或新加坡)。 +6. 点击 **提交** 完成配置。 + +## 使用方法 + +配置完成后,您可以在 Home Assistant 的语音助手设置中选择 **阿里通义千问语音识别** 作为语音转文字 (STT) 引擎。 + +1. 进入 **配置** -> **语音助手**。 +2. 选择或创建一个语音助手。 +3. 在 **语音转文字** 选项中选择 **阿里通义千问语音识别**。 + +## 常见问题 + +**Q: 如何获取 API Key?** +A: 请访问 [阿里云百炼控制台](https://bailian.console.aliyun.com/),开通服务并创建 API Key。 + +**Q: 插件支持哪些 Home Assistant 版本?** +A: 需要 Home Assistant 支持 SpeechToTextEntity(STT 实体)的版本。 + +**Q: 支持哪些语言?** +A: 通义千问 Qwen ASR 支持中文、英文、粤语、日语等多种语言。 + +**Q: 遇到连接失败怎么办?** +A: 请检查 API Key 是否正确,以及网络连接是否正常。如果您在海外,请尝试切换到新加坡区域。 + +**Q: 如何调整 VAD/超时/接入点?** +A: 在集成的“选项”里可以配置自定义 WebSocket 接入点、是否启用 Server VAD、VAD 参数与超时。 + diff --git a/custom_components/hass_stt_qwen/__init__.py b/custom_components/hass_stt_qwen/__init__.py new file mode 100644 index 0000000..2b2c6dd --- /dev/null +++ b/custom_components/hass_stt_qwen/__init__.py @@ -0,0 +1,25 @@ +"""Custom integration for Qwen ASR Speech-to-Text.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +PLATFORMS = ["stt"] + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Qwen ASR from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = entry.data + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/custom_components/hass_stt_qwen/config_flow.py b/custom_components/hass_stt_qwen/config_flow.py new file mode 100644 index 0000000..1d17d34 --- /dev/null +++ b/custom_components/hass_stt_qwen/config_flow.py @@ -0,0 +1,176 @@ +"""Config flow for Qwen ASR Speech-to-Text integration.""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from aiohttp import ClientError, WSMsgType, WSServerHandshakeError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_API_KEY, + CONF_API_URL, + CONF_ENABLE_SERVER_VAD, + CONF_MODEL, + CONF_REGION, + CONF_TIMEOUT, + CONF_VAD_SILENCE_DURATION_MS, + CONF_VAD_THRESHOLD, + DEFAULT_API_URL_BEIJING, + DEFAULT_API_URL_SINGAPORE, + DEFAULT_MODEL, + DEFAULT_REGION, + DOMAIN, + SUPPORTED_MODELS, + SUPPORTED_REGIONS, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): vol.In(SUPPORTED_MODELS), + vol.Optional(CONF_REGION, default=DEFAULT_REGION): vol.In(SUPPORTED_REGIONS), + } +) + +STEP_OPTIONS_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_API_URL): str, + vol.Optional(CONF_ENABLE_SERVER_VAD, default=False): bool, + vol.Optional(CONF_VAD_THRESHOLD, default=0.0): vol.Coerce(float), + vol.Optional(CONF_VAD_SILENCE_DURATION_MS, default=400): vol.Coerce(int), + vol.Optional(CONF_TIMEOUT, default=30): vol.Coerce(int), + } +) + +class QwenSTTConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Qwen ASR Speech-to-Text.""" + + VERSION = 1 + + + async def _async_validate(self, user_input: dict[str, Any]) -> None: + api_key = user_input[CONF_API_KEY] + model = user_input.get(CONF_MODEL, DEFAULT_MODEL) + region = user_input.get(CONF_REGION, DEFAULT_REGION) + api_url = ( + DEFAULT_API_URL_SINGAPORE if region == "singapore" else DEFAULT_API_URL_BEIJING + ) + uri = f"{api_url}/realtime?model={model}" + headers = { + "Authorization": f"Bearer {api_key}", + "OpenAI-Beta": "realtime=v1", + } + + session = async_get_clientsession(self.hass) + async with session.ws_connect(uri, headers=headers, heartbeat=30) as ws: + await ws.send_json( + { + "event_id": "event_validate", + "type": "session.update", + "session": { + "modalities": ["text"], + "input_audio_format": "pcm", + "sample_rate": 16000, + "input_audio_transcription": {"language": "zh"}, + "turn_detection": None, + }, + } + ) + async with asyncio.timeout(5): + async for msg in ws: + if msg.type != WSMsgType.TEXT: + continue + try: + data = msg.json() + except Exception: + continue + msg_type = data.get("type") + if msg_type in ("session.created", "session.updated"): + return + if msg_type == "error": + raise ClientError(str(data.get("error", {}))) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors: dict[str, str] = {} + + # Check if already configured + if self._async_current_entries(): + return self.async_abort(reason="already_configured") + + try: + await self._async_validate(user_input) + except WSServerHandshakeError as err: + if err.status in (401, 403): + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + except TimeoutError: + errors["base"] = "cannot_connect" + except ClientError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error validating Qwen ASR configuration") + errors["base"] = "cannot_connect" + + if errors: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + return self.async_create_entry(title="Qwen ASR", data=user_input) + + @staticmethod + def async_get_options_flow(config_entry: config_entries.ConfigEntry) -> config_entries.OptionsFlow: + return QwenSTTOptionsFlow(config_entry) + + +class QwenSTTOptionsFlow(config_entries.OptionsFlow): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + super().__init__(config_entry) + + async def async_step_init(self, user_input: dict[str, Any] | None = None) -> FlowResult: + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + opts = self.config_entry.options + schema = vol.Schema( + { + vol.Optional(CONF_API_URL, default=opts.get(CONF_API_URL, "")): str, + vol.Optional( + CONF_ENABLE_SERVER_VAD, + default=opts.get(CONF_ENABLE_SERVER_VAD, False), + ): bool, + vol.Optional( + CONF_VAD_THRESHOLD, + default=opts.get(CONF_VAD_THRESHOLD, 0.0), + ): vol.Coerce(float), + vol.Optional( + CONF_VAD_SILENCE_DURATION_MS, + default=opts.get(CONF_VAD_SILENCE_DURATION_MS, 400), + ): vol.Coerce(int), + vol.Optional( + CONF_TIMEOUT, + default=opts.get(CONF_TIMEOUT, 30), + ): vol.Coerce(int), + } + ) + + return self.async_show_form(step_id="init", data_schema=schema) diff --git a/custom_components/hass_stt_qwen/const.py b/custom_components/hass_stt_qwen/const.py new file mode 100644 index 0000000..c8d6637 --- /dev/null +++ b/custom_components/hass_stt_qwen/const.py @@ -0,0 +1,48 @@ +"""Constants for the Qwen ASR Speech-to-Text integration.""" + +DOMAIN = "hass_stt_qwen" + +CONF_API_KEY = "api_key" +CONF_API_URL = "api_url" +CONF_MODEL = "model" +CONF_REGION = "region" +CONF_ENABLE_SERVER_VAD = "enable_server_vad" +CONF_VAD_THRESHOLD = "vad_threshold" +CONF_VAD_SILENCE_DURATION_MS = "vad_silence_duration_ms" +CONF_TIMEOUT = "timeout" + +DEFAULT_API_URL_BEIJING = "wss://dashscope.aliyuncs.com/api-ws/v1" +DEFAULT_API_URL_SINGAPORE = "wss://dashscope-intl.aliyuncs.com/api-ws/v1" +DEFAULT_MODEL = "qwen3-asr-flash-realtime" +DEFAULT_REGION = "beijing" + +SUPPORTED_MODELS = [ + "qwen3-asr-flash-realtime", + "qwen3-asr-flash-realtime-2025-10-27" +] + +SUPPORTED_REGIONS = [ + "beijing", + "singapore", +] + +# Qwen ASR supports multiple languages +SUPPORTED_LANGUAGES = [ + "zh", # Chinese + "en", # English + "yue", # Cantonese + "ja", # Japanese + "ko", # Korean + "es", # Spanish + "fr", # French + "de", # German + "it", # Italian + "pt", # Portuguese + "ru", # Russian + "ar", # Arabic + "tr", # Turkish + "vi", # Vietnamese + "th", # Thai + "id", # Indonesian + "ms", # Malay +] diff --git a/custom_components/hass_stt_qwen/manifest.json b/custom_components/hass_stt_qwen/manifest.json new file mode 100644 index 0000000..b188900 --- /dev/null +++ b/custom_components/hass_stt_qwen/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "hass_stt_qwen", + "name": "阿里通义千问语音识别", + "codeowners": ["@CircleLiu"], + "config_flow": true, + "documentation": "https://bbs.nextrt.com/d/2-home-assistant-a-li-yun-tong-yi-qian-wen-stt-yu-yin-shi-bie-cha-jian", + "iot_class": "cloud_push", + "issue_tracker": "https://bbs.nextrt.com/d/2-home-assistant-a-li-yun-tong-yi-qian-wen-stt-yu-yin-shi-bie-cha-jian", + "version": "0.1.0" +} diff --git a/custom_components/hass_stt_qwen/qwen3_asr_client.py b/custom_components/hass_stt_qwen/qwen3_asr_client.py new file mode 100644 index 0000000..2b47596 --- /dev/null +++ b/custom_components/hass_stt_qwen/qwen3_asr_client.py @@ -0,0 +1,372 @@ +"""WebSocket client for Qwen3 ASR.""" + +from __future__ import annotations + +import asyncio +import base64 +from collections.abc import AsyncIterable +import json +import logging +import time +from typing import Final + +from aiohttp import ClientError, WSCloseCode, WSMsgType + +from homeassistant.components.stt import SpeechMetadata, SpeechResult, SpeechResultState + +_LOGGER = logging.getLogger(__name__) + +# Maximum time to wait for a response (in seconds) +WEBSOCKET_TIMEOUT: Final = 30 + + +class Qwen3AsrClientError(Exception): + pass + + +class Qwen3AsrClientTimeout(Qwen3AsrClientError): + pass + + +class Qwen3AsrClient: + """WebSocket client for Qwen3 ASR API.""" + + def __init__( + self, + client, + api_key: str, + api_url: str, + model: str, + ) -> None: + """Initialize the WebSocket client.""" + self.client = client + self.api_key = api_key + self.api_url = api_url + self.model = model + self.start_time = 0.0 + self.ws = None + self.timeout = WEBSOCKET_TIMEOUT + self.enable_server_vad = False + self.vad_threshold = 0.0 + self.vad_silence_duration_ms = 400 + + def _new_event_id(self) -> str: + return f"event_{int(time.time() * 1000)}" + + def _normalize_language(self, language: str | None) -> str: + if not language: + return "zh" + lower = language.lower() + if lower.startswith("zh"): + return "zh" + return lower + + async def _iter_pcm16(self, stream: AsyncIterable[bytes]) -> AsyncIterable[bytes]: + buf = bytearray() + state = "probe" + pos = 0 + data_remaining: int | None = None + + async for chunk in stream: + if state == "raw": + if chunk: + yield chunk + continue + + if chunk: + buf.extend(chunk) + + if state == "probe": + if len(buf) < 12: + continue + if buf[:4] != b"RIFF" or buf[8:12] != b"WAVE": + yield bytes(buf) + buf.clear() + state = "raw" + continue + state = "wav_chunks" + pos = 12 + + if state == "wav_chunks": + while True: + if len(buf) < pos + 8: + break + chunk_id = bytes(buf[pos : pos + 4]) + chunk_size = int.from_bytes(buf[pos + 4 : pos + 8], "little") + end = pos + 8 + chunk_size + (chunk_size % 2) + if len(buf) < end: + break + if chunk_id == b"data": + del buf[: pos + 8] + data_remaining = chunk_size + state = "wav_data" + break + pos = end + if pos > 65536: + yield bytes(buf) + buf.clear() + state = "raw" + break + + if state == "wav_data" and data_remaining is not None: + while buf and data_remaining > 0: + take = min(len(buf), data_remaining) + yield bytes(buf[:take]) + del buf[:take] + data_remaining -= take + if data_remaining == 0: + if buf: + yield bytes(buf) + buf.clear() + state = "raw" + + async def _send_audio_stream(self, stream: AsyncIterable[bytes]) -> None: + """Send audio chunks to WebSocket server.""" + try: + chunk_count = 0 + async for chunk in self._iter_pcm16(stream): + if not chunk or self.ws.closed: + _LOGGER.debug("Stopping audio send: empty chunk or closed connection") + break + # Audio data must be base64 encoded + b64 = base64.b64encode(chunk).decode("utf-8") + await self.ws.send_json( + { + "event_id": self._new_event_id(), + "type": "input_audio_buffer.append", + "audio": b64, + } + ) + chunk_count += 1 + _LOGGER.debug("Audio chunk #%d sent (%d bytes)", chunk_count, len(chunk)) + + if not self.ws.closed: + # Signal the end of the audio stream to the server + _LOGGER.info("Sent %d audio chunks, sending finish", chunk_count) + if not self.enable_server_vad: + await self.ws.send_json( + { + "event_id": self._new_event_id(), + "type": "input_audio_buffer.commit", + } + ) + await self.ws.send_json( + { + "event_id": self._new_event_id(), + "type": "session.finish", + } + ) + + # Set start time after sending all audio data + self.start_time = time.perf_counter() + + except asyncio.CancelledError: + _LOGGER.debug("send_audio() was cancelled") + raise + except Exception: + _LOGGER.exception("Error sending audio") + if not self.ws.closed: + await self.ws.close( + code=WSCloseCode.INTERNAL_ERROR, + message=b"Error sending audio", + ) + raise + + async def _receive_transcription(self, send_task: asyncio.Task) -> str: + """Receive transcription results from WebSocket server.""" + final_text: str | None = None + try: + async with asyncio.timeout(self.timeout): + async for msg in self.ws: + if msg.type == WSMsgType.TEXT: + data = json.loads(msg.data) + msg_type = data.get("type") + _LOGGER.debug("Received message type: %s", msg_type) + + if msg_type == "session.created": + _LOGGER.info("Session created: %s", data.get("session", {}).get("id")) + elif msg_type == "session.updated": + _LOGGER.info("Session updated") + elif msg_type == "conversation.item.input_audio_transcription.text": + text = data.get("text") or data.get("stash") or "" + if text: + _LOGGER.debug('Intermediate transcription: "%s"', text) + elif msg_type == "conversation.item.input_audio_transcription.completed": + # Get final transcription + final_text = (data.get("transcript") or data.get("text") or "").strip() + if self.start_time > 0: + duration = time.perf_counter() - self.start_time + _LOGGER.info( + "Transcription processing duration: %.2f seconds", + duration, + ) + _LOGGER.info('Final transcription received: "%s"', final_text) + return final_text or "" + elif msg_type == "input_audio_buffer.speech_started": + _LOGGER.info("Speech started") + elif msg_type == "input_audio_buffer.speech_stopped": + _LOGGER.info("Speech stopped") + elif msg_type == "input_audio_buffer.committed": + _LOGGER.info("Audio buffer committed") + elif msg_type == "session.finished": + final_text = (data.get("transcript") or final_text or "").strip() + _LOGGER.info('Session finished: "%s"', final_text) + return final_text or "" + elif msg_type == "error": + error_msg = data.get("error", {}) + raise Qwen3AsrClientError(str(error_msg)) + else: + _LOGGER.debug("Unhandled message type: %s, data: %s", msg_type, data) + elif msg.type == WSMsgType.BINARY: + _LOGGER.warning("Received unexpected binary message (%d bytes)", len(msg.data)) + elif msg.type == WSMsgType.ERROR: + raise Qwen3AsrClientError(str(self.ws.exception())) + elif msg.type == WSMsgType.CLOSED: + _LOGGER.info("WebSocket closed by server") + break + else: + _LOGGER.debug("Received message of type: %s", msg.type) + except TimeoutError: + raise Qwen3AsrClientTimeout("Timeout waiting for transcription response") + except asyncio.CancelledError: + _LOGGER.debug("receive_transcription() was cancelled") + raise + except Qwen3AsrClientError: + raise + except Exception as err: + raise Qwen3AsrClientError(str(err)) from err + finally: + if not send_task.done(): + send_task.cancel() + + if final_text is not None: + return final_text + raise Qwen3AsrClientError("WebSocket closed before transcription completed") + + def _create_session_config(self, metadata: SpeechMetadata) -> dict: + """Create configuration for the transcription session.""" + language = self._normalize_language(metadata.language) + if self.enable_server_vad: + turn_detection = { + "type": "server_vad", + "threshold": self.vad_threshold, + "silence_duration_ms": self.vad_silence_duration_ms, + } + else: + turn_detection = None + config = { + "event_id": self._new_event_id(), + "type": "session.update", + "session": { + "modalities": ["text"], + "input_audio_format": "pcm", + "sample_rate": 16000, + "input_audio_transcription": { + "language": language, + }, + "turn_detection": turn_detection, + }, + } + return config + + async def _handle_tasks( + self, send_task: asyncio.Task, recv_task: asyncio.Task + ) -> None: + """Handle task completion and cancellation logic.""" + try: + done, pending = await asyncio.wait( + [send_task, recv_task], + return_when=asyncio.FIRST_COMPLETED, + ) + + # Handle task completion and cancellation + if recv_task in done: + _LOGGER.debug("Transcription finished - cancelling audio task") + if not send_task.done(): + send_task.cancel() + await asyncio.gather(send_task, return_exceptions=True) + elif send_task in done: + _LOGGER.debug("Audio finished - waiting for final transcription") + await recv_task + else: + _LOGGER.warning( + "Unexpected state in task completion, ensuring tasks are awaited/cancelled" + ) + for task in pending: + if not task.done(): + task.cancel() + await asyncio.gather(*pending, return_exceptions=True) + + for task in done: + if task.exception(): + _LOGGER.error("Task completed with exception: %s", task.exception()) + finally: + if not self.ws.closed: + try: + await self.ws.close() + _LOGGER.debug("WebSocket closed cleanly") + except Exception: + _LOGGER.exception("Error closing WebSocket connection") + + async def async_process_audio_stream( + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] + ) -> SpeechResult: + """Process audio stream via WebSocket to Qwen3 ASR Realtime API.""" + + # Construct WebSocket URL for realtime API + uri = f"{self.api_url}/realtime?model={self.model}" + headers = { + "Authorization": f"Bearer {self.api_key}", + "OpenAI-Beta": "realtime=v1", + } + + _LOGGER.info( + "Starting Qwen3 ASR transcription: language=%s, format=%s, sample_rate=%s, channels=%s", + metadata.language, + metadata.format, + metadata.sample_rate, + metadata.channel, + ) + + try: + _LOGGER.debug("Opening WebSocket connection to %s", uri) + async with self.client.ws_connect(uri, headers=headers, heartbeat=30) as ws: + self.ws = ws + self.start_time = 0 # Reset start_time + + config = self._create_session_config(metadata) + _LOGGER.debug("Sending session configuration: %s", config) + await ws.send_json(config) + + # Create and manage concurrent tasks + send_task = asyncio.create_task(self._send_audio_stream(stream)) + recv_task = asyncio.create_task(self._receive_transcription(send_task)) + + # Handle tasks completion + await self._handle_tasks(send_task, recv_task) + + # Process final result + if not recv_task.done() or recv_task.cancelled(): + return SpeechResult("", SpeechResultState.ERROR) + + exc = recv_task.exception() + if exc: + _LOGGER.error("Transcription failed: %s", exc) + return SpeechResult("", SpeechResultState.ERROR) + + final_text = recv_task.result().strip() + + _LOGGER.info('Transcription completed successfully: "%s"', final_text) + + if not final_text: + _LOGGER.warning("Qwen3 ASR transcription resulted in empty text") + return SpeechResult("", SpeechResultState.SUCCESS) + + return SpeechResult(final_text, SpeechResultState.SUCCESS) + + except ClientError as err: + _LOGGER.error("WebSocket connection error: %s", err) + return SpeechResult("", SpeechResultState.ERROR) + except Exception: + _LOGGER.exception("Unexpected error in WebSocket communication") + return SpeechResult("", SpeechResultState.ERROR) diff --git a/custom_components/hass_stt_qwen/stt.py b/custom_components/hass_stt_qwen/stt.py new file mode 100644 index 0000000..c0a4aa8 --- /dev/null +++ b/custom_components/hass_stt_qwen/stt.py @@ -0,0 +1,181 @@ +"""Setting up QwenSTTProvider.""" +from __future__ import annotations + +from collections.abc import AsyncIterable +import logging + +from homeassistant.components.stt import ( + AudioBitRates, + AudioChannels, + AudioCodecs, + AudioFormats, + AudioSampleRates, + SpeechMetadata, + SpeechResult, + SpeechResultState, +) +from homeassistant.components.stt import SpeechToTextEntity + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + CONF_API_KEY, + CONF_API_URL, + CONF_ENABLE_SERVER_VAD, + CONF_MODEL, + CONF_REGION, + CONF_TIMEOUT, + CONF_VAD_SILENCE_DURATION_MS, + CONF_VAD_THRESHOLD, + DEFAULT_API_URL_BEIJING, + DEFAULT_API_URL_SINGAPORE, + DEFAULT_MODEL, + DEFAULT_REGION, + DOMAIN, + SUPPORTED_LANGUAGES, +) +from .qwen3_asr_client import Qwen3AsrClient + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Qwen STT from a config entry.""" + data = config_entry.data + options = config_entry.options + + api_key = data[CONF_API_KEY] + model = data.get(CONF_MODEL, DEFAULT_MODEL) + region = data.get(CONF_REGION, DEFAULT_REGION) + + api_url = options.get(CONF_API_URL) or DEFAULT_API_URL_SINGAPORE if region == "singapore" else DEFAULT_API_URL_BEIJING + enable_server_vad = options.get(CONF_ENABLE_SERVER_VAD, False) + vad_threshold = options.get(CONF_VAD_THRESHOLD, 0.0) + vad_silence_duration_ms = options.get(CONF_VAD_SILENCE_DURATION_MS, 400) + timeout = options.get(CONF_TIMEOUT, 30) + + async_add_entities( + [ + QwenSTTEntity( + hass, + config_entry, + api_key, + api_url, + model, + enable_server_vad=enable_server_vad, + vad_threshold=vad_threshold, + vad_silence_duration_ms=vad_silence_duration_ms, + timeout=timeout, + ) + ] + ) + + +class QwenSTTEntity(SpeechToTextEntity): + """The Qwen STT entity.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + api_key: str, + api_url: str, + model: str, + *, + enable_server_vad: bool, + vad_threshold: float, + vad_silence_duration_ms: int, + timeout: int, + ) -> None: + """Init Qwen STT service.""" + self.hass = hass + self._config_entry = config_entry + self._api_key = api_key + self._api_url = api_url + self._model = model + self._enable_server_vad = enable_server_vad + self._vad_threshold = vad_threshold + self._vad_silence_duration_ms = vad_silence_duration_ms + self._timeout = timeout + self._client = self._create_client() + + self._attr_name = f"Qwen ASR ({model})" + self._attr_unique_id = f"{config_entry.entry_id}_{model}" + + @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 bitrates.""" + return [AudioBitRates.BITRATE_16] + + @property + def supported_sample_rates(self) -> list[AudioSampleRates]: + """Return a list of supported samplerates.""" + return [AudioSampleRates.SAMPLERATE_16000] + + @property + def supported_channels(self) -> list[AudioChannels]: + """Return a list of supported channels.""" + return [AudioChannels.CHANNEL_MONO] + + def _create_client(self): + """Create and return the appropriate client based on model.""" + session = async_get_clientsession(self.hass) + + if self._model.startswith("qwen3-asr"): + client = Qwen3AsrClient( + session, + self._api_key, + self._api_url, + self._model, + ) + client.enable_server_vad = self._enable_server_vad + client.vad_threshold = self._vad_threshold + client.vad_silence_duration_ms = self._vad_silence_duration_ms + client.timeout = self._timeout + return client + else: + _LOGGER.error("Unsupported model: %s", self._model) + raise ValueError(f"Unsupported model: {self._model}") + + async def async_process_audio_stream( + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] + ) -> SpeechResult: + """Process audio stream using the configured client.""" + if ( + metadata.format not in self.supported_formats + or metadata.codec not in self.supported_codecs + or metadata.bit_rate not in self.supported_bit_rates + or metadata.sample_rate not in self.supported_sample_rates + or metadata.channel not in self.supported_channels + ): + _LOGGER.error( + "Unsupported audio metadata: format=%s codec=%s bit_rate=%s sample_rate=%s channel=%s", + metadata.format, + metadata.codec, + metadata.bit_rate, + metadata.sample_rate, + metadata.channel, + ) + return SpeechResult("", state=SpeechResultState.ERROR) + return await self._client.async_process_audio_stream(metadata, stream) diff --git a/custom_components/hass_stt_qwen/translations/en.json b/custom_components/hass_stt_qwen/translations/en.json new file mode 100644 index 0000000..bfc0528 --- /dev/null +++ b/custom_components/hass_stt_qwen/translations/en.json @@ -0,0 +1,36 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to Qwen ASR", + "description": "Enter your Alibaba Cloud DashScope API details.", + "data": { + "api_key": "API Key", + "model": "Model", + "region": "Region" + } + } + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication" + }, + "abort": { + "already_configured": "Service is already configured" + } + }, + "options": { + "step": { + "init": { + "title": "Qwen ASR options", + "data": { + "api_url": "WebSocket endpoint (optional)", + "enable_server_vad": "Enable Server VAD", + "vad_threshold": "VAD threshold", + "vad_silence_duration_ms": "VAD silence duration (ms)", + "timeout": "Timeout (seconds)" + } + } + } + } +} diff --git a/custom_components/hass_stt_qwen/translations/zh-Hans.json b/custom_components/hass_stt_qwen/translations/zh-Hans.json new file mode 100644 index 0000000..34fa3b2 --- /dev/null +++ b/custom_components/hass_stt_qwen/translations/zh-Hans.json @@ -0,0 +1,36 @@ +{ + "config": { + "step": { + "user": { + "title": "连接到阿里云 Qwen ASR", + "description": "请输入您的阿里云 DashScope API 信息。", + "data": { + "api_key": "API 密钥", + "model": "模型", + "region": "区域" + } + } + }, + "error": { + "cannot_connect": "连接失败", + "invalid_auth": "无效的身份验证" + }, + "abort": { + "already_configured": "服务已配置" + } + }, + "options": { + "step": { + "init": { + "title": "Qwen ASR 选项", + "data": { + "api_url": "WebSocket 接入点(可选)", + "enable_server_vad": "启用 Server VAD", + "vad_threshold": "VAD 阈值", + "vad_silence_duration_ms": "VAD 静默时长(毫秒)", + "timeout": "超时(秒)" + } + } + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..4140753 --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "阿里语音识别", + "render_readme": true, + "country": ["CN"] +}