# dsh-voice-local

DeepSeek Harness（dsh）Web UI 本地语音输入插件。

- **全程本地离线**：录音与转写都在本机完成，**音频不出本机**、不依赖云端 API/API Key。
- **引擎**：SenseVoice（sherpa-onnx-node，CPU 推理），支持中文自动标点及中/英/日/韩/粤多语言。
- **句子级实时**：浏览器端静音检测断句，说一句自动回填一句，可继续说话；识别文本按光标位置插入。
- **安全**：宿主路由只允许 loopback / trustedHosts 访问，非本机请求返回 403。
- **安装**：标准 DSH bundle（`dsh.bundle.patch`），走 DSH 自带插件接口，支持**包名**与 **GitHub 仓库地址**两种来源，见[安装](#安装)。

## 安装

DSH 自带插件接口，两种来源任选其一：填进「包名或地址」，点**安装**，装完再点**立即启用**。（入口：Web GUI **侧边栏「插件」页 → 「添加插件」**。）

### 方式一：包名（推荐）

```text
dsh-voice-local
```

也可以钉版本，例如 `dsh-voice-local@0.4.1`（**不加版本号即装最新版**，推荐）。这条走 npm registry，需要包已发布（本插件已发布）。

### 方式二：GitHub 仓库地址

```text
https://github.com/Real-WangLe/dsh-voice-local
```

想跟进尚未发版的改动时用这条。仓库里**已包含构建产物 `dist/`**，因此安装过程不执行构建、也不需要授权任何安装脚本。

### 生效时机

装完**一般立即生效**——DSH 的 profile 是热插拔的，新增插件会当场加载，不需要重启。若界面没变化，先刷新一下浏览器页面。

只有成功界面出现「**已安装，下次启动后加载。**」时才需要重启 `dsh web`。这种情况出现在**覆盖安装同一个包**时，例如从 `dsh-voice-local` 换成 `dsh-voice-local@0.4.1`、或换个来源重装同名插件——模块已经在进程里，必须重启才能换成新代码。

### 命令行等价写法

```bash
dsh plugin --profile web add dsh-voice-local                      # 方式一：包名
dsh plugin --profile web add github:Real-WangLe/dsh-voice-local   # 方式二：GitHub 仓库
# 新增安装立即生效；覆盖安装同名包需重启 dsh web
```

首次点击麦克风会自动在后台下载 SenseVoice 模型（约 230MB），并显示进度提示；下载完成后即可开始录音。

## 手动下载 / 离线导入

如果自动下载在你的网络环境下较慢，可以手动下载模型文件后放到固定目录，插件检测到后不会再触发下载：

```bash
mkdir -p ~/.dsh/voice/sensevoice
cd ~/.dsh/voice/sensevoice
# 用浏览器/IDM/迅雷等工具下载官方 tar.bz2 并解压：
#   https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17.tar.bz2
# 解压后确认目录中有：
#   model.int8.onnx
#   tokens.txt
```

也可以使用 `DSH_VOICE_MIRROR_URL` 或 `DSH_VOICE_MIRRORS` 指定更快镜像后运行：

```bash
DSH_VOICE_MIRROR_URL="https://your-mirror/...tar.bz2" node tools/download-model.mjs
```

## 使用

1. 打开任意会话，点击输入框工具行麦克风按钮开始录音。
2. 说话；检测到停顿时自动转写并**插入输入框光标位置**（光标在末尾时等价追加），光标随新文本后移。
3. 再次点击麦克风按钮停止录音，未定稿的剩余语音会完成最后一次转写。
4. 识别文本**不会自动发送**，发送前可编辑。

### 语音快捷键

默认 **Ctrl+Alt+V**（macOS 为 **⌘⌥V**，V for Voice），触发语义与点击麦克风按钮**完全一致**：空闲时开始、录音中停止并冲刷尾段、启动中取消；键名不区分大小写。

- **改绑**：麦克风旁的 **⋮ 菜单** → 「快捷键」行，点击后显示「请按下快捷键…」，按下组合即绑定并持久化（刷新后仍生效）；行内 **✕** 恢复默认。
- **目标按焦点解析**：焦点在提问卡回答框 → 录向该卡；焦点在主输入框（含麦克风按钮本身）或页面空白 → 录向主输入框；焦点在设置页搜索框等其他可编辑控件 → **不触发、不劫持按键**。**停止录音不依赖焦点**：录音中只要焦点还在页面内，按快捷键即停止并冲刷尾段（焦点在 DevTools 或浏览器地址栏时任何网页都收不到按键）。
- **保留键当场拒绝**（Ctrl/⌘ + W/T/N/Tab/L、F11/F12、Alt+F4、只按修饰键）；**Space 系组合**（`Ctrl+Space`、`Ctrl+Shift+Space`）**允许绑定但会提示**可能被输入法/系统/浏览器扩展占用——`Ctrl+Shift+Space` 是 Chrome 翻译类扩展（Google Translate 等）的默认占位，会被吞掉导致"按了没反应"，因此默认键位已改为一组双修饰键 + 字母。
- 主键按**物理键位**匹配：macOS 的 Option 组合（`event.key` 会变成 `√` 等符号）与非美式键盘布局下同样可用。
- 输入法组合过程中的按键、按住不放的自动重复都不会触发；录音中按回车发送时麦克风自动停止（见下）。

### 键盘自停（默认开启）

麦克风旁的 **⋮ 按钮** 打开语音设置菜单（右上角弹出），内含说明与开关「键盘输入自动关闭麦克风」：

- **开启（默认）**：录音期间打字 / 粘贴 / 中文输入法 / 撤销重做会立即停止录音（未定稿尾段丢弃，避免迟到转写插进手打内容之后）。
- **关闭**：保留 v1 边说边打工作流，录音不中断，转写跟随光标插入。

**回车发送即停（与上面的开关无关）**：主输入框录音中按下不带 Shift 的 Enter 或 Ctrl/⌘+Enter（= 宿主的发送手势）会立即停止录音并**丢弃**未定稿尾段——消息已经发出，迟到的识别文本不应落进发送后新建的空草稿。`Shift+Enter`（换行）与提问卡回答框里的回车不触发。

提问卡片的回答框、审批/计划审阅卡弹出时同样适用自动停止逻辑。

另外，主输入框录音中若弹出提问 / 审批 / 计划审阅卡片，会自动停止录音并把尾段落回草稿，避免“隐形录音”。

### 提问卡片语音回答

agent 通过 ask-user_question 提问时，自定义回答输入区旁会出现同一枚麦克风：说话即插入当前题回答框光标处，不改变已选项、不自动提交；翻页自动跟随。若 DSH 升级后入口消失（锚点变化），插件静默降级不影响卡片本身，可用 `npm run doctor` 查看 `q-anchor` 诊断。

### 噪声过滤（默认开启）

插件内置两级噪声漏斗，环境杂音（风扇、键盘、背景音乐）不再被写成文字：

- **浏览器端自适应门控**：持续跟踪环境噪声底，双门限迟滞判定说话起止；触发前自动回带 250ms 保证句首完整，短促敲击类声音不足最短人声时长直接不上传。
- **宿主端人声守门**：每段音频上传后先经 GTCRN 神经降噪（可选），再由本地 Silero VAD 判定人声——累计人声不足 400ms 的段直接返回空文本，不会调用识别模型。
- **文本幻觉兜底**："谢谢观看。"、字幕署名、单字符超长重复等识别残留被规则拦空，正常中英文直通。

两个过滤器模型（Silero VAD ~2.2MB、GTCRN <2MB）与主模型一样**不打包进 npm**，首次使用时自动后台下载；两者位于不同的官方发布标签（VAD 在 `asr-models`，GTCRN 在 `speech-enhancement-models`）。它们是增强组件：缺失或加载失败时自动旁路（fail-open），听写照常可用，`npm run doctor` 与 `/health` 会如实标注状态。

**存量资产自动补齐**：主模型已就绪但过滤器资产缺失或为 0 字节时（例如从 0.3.0 升级、GTCRN 曾因地址错配从未下载成功），插件会在下次录音时后台触发一次补下载；不重复下载主模型、不阻塞录音，补齐后无需重启即生效。

`/health` 的 `filters.vad` 暴露守门运行计数与最近 10 次判定值（`judgedRuns` / `emptyRuns` / `recentSpeechMs`）、`filters.denoiser` 暴露运行次数与最近错误，可一眼辨识“判定值恒为常数”或“组件每次都失败”这类静默失效；守门判空时 `/transcribe` 响应（无需开 debug）会附带 `gateReason`，客户端在同一会话内连续两段判空时给出一次可读提示，而不是静默无反应。

## 配置

可选环境变量：

| 变量 | 说明 | 默认 |
|:--|:--|:--|
| `DSH_HOME` | dsh 数据目录，模型存到 `$DSH_HOME/voice/sensevoice` | `~/.dsh` |
| `DSH_VOICE_MODEL_DIR` | 模型目录覆盖 | 默认目录 |
| `DSH_VOICE_MODEL_URL` | 模型下载主 URL | 官方 GitHub release |
| `DSH_VOICE_MIRROR_URL` | 单个镜像 URL（最先尝试） | 无 |
| `DSH_VOICE_MIRRORS` | 逗号分隔的镜像 URL 列表（按顺序尝试） | 内置 ghfast.top/gh-proxy.com + hf-mirror 直连文件 |
| `DSH_VOICE_MODEL_SHA256` | 模型归档 SHA256（可选强校验） | 内置值（若已发布） |
| `DSH_VOICE_MODEL_FILE_SHA256` | 直连文件模式下 model.int8.onnx 的 SHA256（可选） | 无 |
| `DSH_VOICE_TOKENS_SHA256` | 直连文件模式下 tokens.txt 的 SHA256（可选） | 无 |

也可以在 profile 的 `cordis.patch.yml` 中覆盖：

```yaml
- insert:
    - id: dsh-voice-local
      name: dsh-voice-local
      config:
        modelUrl: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/...'
        mirrorUrl: 'https://your-mirror/...'          # 单个首选镜像
        mirrors: ['https://mirror-a/...', 'https://mirror-b/...']  # 按顺序尝试
        modelDir: '/absolute/path/to/model'
        sha256: '<sha256>'
        # 噪声过滤（均可选，默认即启用）
        vad: { enabled: true, minSpeechMs: 400 }   # 人声守门；低于门限的段不送识别
        denoise: { enabled: true }                 # GTCRN 降噪后再识别（含噪环境准确率更高）
        debug: false                               # true 时 /transcribe 附带 meta 诊断字段
```

## 宿主路由

| 方法 | 路径 | 说明 |
|:--|:--|:--|
| GET | `/dsh-voice-local/v1/health` | 插件/原生模块/模型状态 |
| GET | `/dsh-voice-local/v1/model/status` | 模型文件与下载进度 |
| POST | `/dsh-voice-local/v1/model/download` | 启动后台模型下载 |
| POST | `/dsh-voice-local/v1/transcribe` | 上传 WAV，返回识别文本 |
| GET | `/dsh-voice-local/v1/diagnose` | 原生模块/重采样器诊断 |

所有路由仅允许本机回环或 DSH `trustedHosts` 来源访问。

## 诊断

```bash
node scripts/doctor.mjs            # 完整诊断
node scripts/doctor.mjs --json     # 机器可读
node tools/download-model.mjs      # 手动下载模型
```

## 开发

```bash
npm install
npm run build        # 构建 dist/：复制 lib/ 并拼接浏览器 bundle dist/client.js
npm test             # 单元 + 路由集成测试
npm run test:e2e     # jsdom 浏览器端 e2e（快捷键/目标解析/回车自停/IME 延后）
node scripts/smoke.mjs [wav] [expected.txt]   # 真实模型慢速冒烟（无参默认用 test/fixtures/voice-zh-16k.wav）
```

> **`dist/` 随仓库提交**：官方「GitHub 仓库地址」安装方式直接从 git 取包、不执行构建，产物必须随仓库提供。改完 `lib/` 请运行 `npm run build` 并提交 `dist/`——CI 的 `dist-fresh` 作业会重新构建并校验它与 `lib/` 同步。

慢速真模型 lane 也会在打 `v*` tag / 发布 Release 前由 `.github/workflows/smoke.yml` 自动触发，不拖累每次 PR。

## License

本项目采用 MIT License。

### 致谢

本项目部分改编自社区插件包 `dsh-voice-input`。参考包元数据标注的来源为 `fuzhailv`，原始实现采用 MIT License。

本项目保留了原始 MIT 许可声明，并对 SenseVoice 转写封装、原生模块诊断、模型管理、宿主路由以及浏览器端录音与断句逻辑进行了改造和扩展。

由于目前无法核验原始参考包的公开仓库地址，本项目不附未经确认的外部链接。
