-
-
Notifications
You must be signed in to change notification settings - Fork 2.4k
fix(sensevoice): 修复 SenseVoice STT Provider 多个问题 #8878
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import asyncio | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import os | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import re | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from typing import cast | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -22,6 +23,11 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "sensevoice_stt_selfhost", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "SenseVoice 自托管语音识别 模型部署", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| provider_type=ProviderType.SPEECH_TO_TEXT, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| default_config_tmpl={ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "id": "sensevoice", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "stt_model": "iic/SenseVoiceSmall", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "is_emotion": False, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class ProviderSenseVoiceSTTSelfHost(STTProvider): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def __init__( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -37,14 +43,110 @@ def __init__( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async def initialize(self) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.info("下载或者加载 SenseVoice 模型中,这可能需要一些时间 ...") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # 将模型加载放到线程池中执行 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _load_model(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return SenseVoiceSmall(self.model_name, quantize=True, batch_size=16) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| err_str = str(e) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if "Type parameter (T) of Optype (Less) bound to different types" in err_str: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.info("检测到 ONNX 导出类型不匹配,正在修复导出的模型文件 ...") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._fix_onnx_less_type_mismatch() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # 重试加载,此时 model_quant.onnx 已被修复 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return SenseVoiceSmall( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.model_name, quantize=True, batch_size=16 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| raise | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.model = await asyncio.get_running_loop().run_in_executor( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| None, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| lambda: SenseVoiceSmall(self.model_name, quantize=True, batch_size=16), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| None, _load_model, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.info("SenseVoice 模型加载完成。") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @staticmethod | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _fix_onnx_less_type_mismatch() -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """修复 ONNX 导出时 Less 节点类型不匹配的问题。 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 在 model_quant.onnx 中,arange 输出 FLOAT,但 Less 的第二个输入 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| convert_element_type_default 输出 INT64,导致 Less 的 T 参数冲突。 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 在 arange 后插入 Cast 节点转为 INT64。 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import onnx | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from onnx import helper, TensorProto | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cache_dir = os.path.expanduser( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| os.path.join("~", ".cache", "modelscope", "hub") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| model_quant_path = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+66
to
+80
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 将 原因:
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for root, _dirs, files in os.walk(cache_dir): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+77
to
+81
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (bug_risk): The ONNX fix searches the entire modelscope cache and patches the first Because this walks the entire |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if "model_quant.onnx" in files: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| model_quant_path = os.path.join(root, "model_quant.onnx") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| break | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not model_quant_path or not os.path.exists(model_quant_path): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.error( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "未找到 model_quant.onnx,无法修复 ONNX 类型不匹配。" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| model = onnx.load(model_quant_path) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| graph = model.graph | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # 找到 arange 输出节点和 Less 节点 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| less_node = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| arange_output = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for node in graph.node: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if node.op_type == "Less": | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| less_node = node | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Less 的第二个输入是 arange 输出 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| arange_output = node.input[1] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| break | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if less_node is None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.info("未找到 Less 节点,无需修复。") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+95
to
+107
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 直接获取图中的第一个 建议通过检查
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # 检查 arange_output 的类型 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| arange_output_tensor = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for vi in graph.value_info: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if vi.name == arange_output: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| arange_output_tensor = vi | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| break | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if arange_output_tensor is None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # 也可能是 graph.input | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for vi in graph.input: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if vi.name == arange_output: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| arange_output_tensor = vi | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| break | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if arange_output_tensor is None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.info("无法找到 arange 输出 tensor 信息,跳过修复。") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # 创建 cast_name | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cast_output_name = arange_output + "_cast_int64" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # 插入 Cast 节点:将 FLOAT 转为 INT64 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cast_node = helper.make_node( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "Cast", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| inputs=[arange_output], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| outputs=[cast_output_name], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name=arange_output + "_to_int64", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| to=TensorProto.INT64, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # 修改 Less 节点的第二个输入为 cast 后的输出 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| less_node.input[1] = cast_output_name | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # 将 Cast 节点插入到 Less 节点之前 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| graph.node.insert( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| list(graph.node).index(less_node), cast_node | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+142
to
+145
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| onnx.save(model, model_quant_path) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.info("ONNX 模型文件已修复并保存。") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async def get_text(self, audio_url: str) -> str: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # 使用 run_in_executor 来调用模型进行识别 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| ## SenseVoice STT Provider: 配置后显示"not found"且缺少依赖处理 | ||
|
|
||
| ### 问题描述 | ||
|
|
||
| 在 Dashboard 中启用 SenseVoice STT 语音识别后,测试/检查 Provider 状态时显示: | ||
|
|
||
| ``` | ||
| Provider with id 'sensevoice' not found in provider_manager. | ||
| ``` | ||
|
|
||
| 但实际上 `sensevoice_selfhosted_source.py` 源码文件是存在的。 | ||
|
|
||
| ### 复现步骤 | ||
|
|
||
| **场景一(仅设置 STT 开关,未在 Provider 页添加):** | ||
|
|
||
| 1. 打开 Dashboard → 设置页 → 语音识别选项卡 | ||
| 2. 启用 STT,填写 `provider_id: "sensevoice"` | ||
| 3. 切换到 Provider 页 → Speech-to-Text 选项卡 | ||
| 4. Provider 列表中不存在 sensevoice | ||
| 5. 点击 Test → 显示 "not found" | ||
|
|
||
| **场景二(在 Provider 页添加了,但缺依赖):** | ||
|
|
||
| 1. 打开 Dashboard → Provider 页 → Speech-to-Text 选项卡 | ||
| 2. 点击 + 添加 Provider,选择 SenseVoice(Local) | ||
| 3. 填写配置并保存 → 前端显示"添加成功" | ||
| 4. 点击 Test → 仍显示 "not found" | ||
|
|
||
| ### 根因分析 | ||
|
|
||
| #### 1. 关键依赖未声明(需要手动安装) | ||
|
|
||
| `sensevoice_selfhosted_source.py` 顶部直接 import: | ||
|
|
||
| ```python | ||
| from funasr_onnx import SenseVoiceSmall | ||
| ``` | ||
|
|
||
| 但以下依赖均未列在项目的 `requirements.txt` / `pyproject.toml` 中: | ||
|
|
||
| | 依赖 | 用途 | | ||
| |------|------| | ||
| | `funasr_onnx` | ONNX 推理引擎 | | ||
| | `torch` | PyTorch 模型加载(ONNX 导出步骤需要) | | ||
| | `modelscope` | 从 ModelScope 下载模型 | | ||
| | `funasr` | 完整 funasr 库(ONNX 导出依赖) | | ||
| | `torchaudio` | funasr 间接依赖 | | ||
| | `onnxscript` | `torch.onnx` 导出需要 | | ||
|
|
||
| 用户安装 AstrBot 时不会安装这些包,需要用户自行猜测并手动 pip install。 | ||
|
|
||
| #### 2. `load_provider()` 加载失败时静默吞异常 | ||
|
|
||
| `create_provider()` 流程: | ||
|
|
||
| ``` | ||
| 用户点"添加" → 配置写入 cmd_config.json ✅ → load_provider() → import funasr_onnx 失败 ❌ | ||
| ↓ | ||
| 异常被捕获,只打了一行 log | ||
| ↓ | ||
| provider 不加入 inst_map | ||
| ↓ | ||
| 前端显示"添加成功"(返回了 200 OK) | ||
| ``` | ||
|
|
||
| `load_provider()` 内部的 import 错误被捕获后既不向上抛异常,也不给前端返回错误信息。用户看到的是"添加成功",但 provider 实际上没有被加载到内存。 | ||
|
|
||
| `post_new_provider` 的代码路径: | ||
|
|
||
| ```python | ||
| async def post_new_provider(self): | ||
| new_provider_config = await request.json | ||
| try: | ||
| await self.core_lifecycle.provider_manager.create_provider(new_provider_config) | ||
| except Exception as e: | ||
| return Response().error(str(e)).__dict__ # ← 只有这里会报错 | ||
| return Response().ok(None, "新增服务提供商配置成功").__dict__ | ||
| ``` | ||
|
|
||
| 但 `create_provider()` 调用的 `load_provider()` 内部捕获了异常却没有 re-raise,所以 `post_new_provider` 永远走不到 except 分支。 | ||
|
|
||
| #### 3. `check_one` 无法区分失败原因 | ||
|
|
||
| `check_one_provider_status()` 只查 `inst_map.get(provider_id)`: | ||
|
|
||
| ```python | ||
| target = prov_mgr.inst_map.get(provider_id) | ||
| if not target: | ||
| return Response().error(f"Provider with id '{provider_id}' not found").__dict__ | ||
| ``` | ||
|
|
||
| 它无法区分三种情况: | ||
| - Provider 从未被添加(配置里就没有) | ||
| - Provider 添加了但加载失败(import error / 缺依赖) | ||
| - Provider 初始化失败(模型下载失败、ONNX 导出错误等) | ||
|
|
||
| 统一报 "not found",对用户没有任何排查帮助。 | ||
|
|
||
| #### 4. ONNX 导出模型类型不匹配(依赖齐全后仍会遇到) | ||
|
|
||
| 安装完所有依赖后,`SenseVoiceSmall(model_name, quantize=True)` 初始化时执行 ONNX 导出会出现: | ||
|
|
||
| ``` | ||
| Type Error: Type parameter (T) of Optype (Less) bound to different types | ||
| ``` | ||
|
|
||
| 根因:导出的 `model_quant.onnx` 中有一个 `Less` 节点,其输入 `arange` 输出类型为 FLOAT(elem_type 1),但 `convert_element_type_default` 输出类型为 INT64(elem_type 7),导致 `Less` 节点的类型参数 `T` 绑定冲突。需要在 ONNX 图中插入 Cast 节点修复。 | ||
|
|
||
| #### 5. Provider 配置流程存在断裂 | ||
|
|
||
| STT 设置页的 `provider_stt_settings.provider_id` 和 Provider 页的 `provider` 列表是两个独立的功能。用户可能在设置页直接填写了 `provider_id: "sensevoice"`,但从未在 Provider 页添加过对应的 provider 条目。两者之间缺少联动检查或引导。 | ||
|
|
||
| ### 建议修复 | ||
|
|
||
| 1. **`sensevoice_selfhosted_source.py`**: 补充 `default_config_tmpl` 参数 | ||
| 2. **`pyproject.toml` / `requirements.txt`**: 将 `funasr_onnx` 及其依赖列为可选依赖(extra / optional) | ||
| 3. **`provider/manager.py` `load_provider`**: 加载失败时向上抛异常或通过回调通知前端,而不是静默吞掉 | ||
| 4. **`provider/manager.py` / `check_one_provider_status`**: 在 provider 记录中保存加载错误信息,`check_one` 时一并返回,而不是笼统报 "not found" | ||
| 5. **`sensevoice_selfhosted_source.py` `initialize()`**: ONNX 导出后自动修复类型不匹配(或改为直接使用 PyTorch 推理跳过 ONNX 导出) | ||
| 6. **Dashboard 交互**: 配置页的 provider 选择器和 Provider 页之间增加联动,provider 不存在时给出明确引导 | ||
|
|
||
| ### 环境 | ||
|
|
||
| - AstrBot 版本: v4.25.5 | ||
| - 操作系统: Windows 11 | ||
| - Python: 3.12 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
question (bug_risk): Re-raising here changes the behavior from ‘best-effort load all providers’ to ‘fail on first problematic provider’.
Previously, a failed provider import/instantiation was logged and skipped so other providers could still load. Raising here means one bad provider can now abort the entire load (and possibly app startup). If this change is intentional, verify that callers expect and handle this exception. If not, consider keeping the failure scoped to the single provider while still surfacing enough detail for diagnostics/reporting.