Bundle
dsh-speech
Speech capability plugin for the DeepSeek Harness (dsh) web host: a token-gated /s/api route family serving audio transcription (ASR) and synthesis (TTS) over configurable providers
- Source
- agent-mobile
- stars
- 1 stars
- License
- MIT
- Updated
- Updated yesterday
Readme
# dsh-speech
> DeepSeek Harness(DSH)语音能力插件:装一个插件,主机就拥有 token 门控的
> `/s/api` 语音转写(ASR)与语音合成(TTS)服务面、`/s/ws` 实时转录会话通道,
> 手机 App / 任何局域网客户端直接调用。不改 dsh 源码、不需要重新下载编译——
> `dsh plugin` 一条命令安装。
配套手机端(dsh_mobile_app / dsh_dart_sdk)走 mobile-gateway 的 `/m/api` 聊天,
语音请求走本插件的 `/s/api` 与 `/s/ws`,两者共用同一个 token。
语音模式 / 实时转写的用法与语音链路、组网(蒲公英 · WireGuard)配置,见 **[VOICE-AND-NETWORK.md](VOICE-AND-NETWORK.md)**。
## 它解决什么问题
dsh 官方宿主没有任何语音能力位(LLM seam 只认 chat 模态,附件只收图片)。
本插件以官方扩展点(webServer 路由注册)旁挂独立的语音 HTTP/WS 面:
| 动作 | 机制 |
| --- | --- |
| 多提供商 ASR/TTS | 插件内 `SpeechService` 注册表,`dashscope` / `openai-compatible` / `local-relay` / `streaming-ws` 四类 adapter |
| 阿里云百炼(原生) | `dashscope` 走百炼原生 HTTP 协议(`multimodal-generation` 端点):批量识别/合成 + 句级实时转录(VAD 复用批量 ASR)。内置平台预设,模型列表可「拉取」 |
| 云端厂商通吃 | `openai-compatible` 说标准 `/v1/audio/transcriptions` + `/v1/audio/speech`(OpenAI、Groq、SiliconFlow、Fireworks、自建兼容网关……换厂商只改配置) |
| 本地服务直连 | `local-relay` 说自建 SenseVoice(`POST {asr}/transcribe`,base64 WAV→`{text}`)与 CosyVoice2 的私有契约:批量 `POST {tts}/synthesize`(`{text,voice,stream:false}`→WAV)、流式 `WS {tts}/ws/tts`(增量 PCM16 帧→连续 WAV 流,边合成边播),自动归一化采样率/声道 |
| 实时转录(本地/百炼) | `local-relay` / `dashscope` 复用同一批量 ASR:主机 VAD 分段 + 伪流式逐字预览(句级出稿)。**说话人分离仅 `local-relay` 提供**(`include_embedding` 声纹聚类);百炼实时转录无 speaker 字段(见 dashscope 段能力边界) |
| 实时转录(云端流式) | `streaming-ws` 直连流式 ASR WebSocket:Deepgram / FunASR wss-server / sherpa-onnx / 讯飞 iat v2(单人听写,自动轮转 60s 上限)/ 讯飞 rtasr v1(长音频多人实时转写,`roleType=2` 说话人分离,静音保活防 15s 断连) |
| 云端合成(讯飞) | `streaming-ws` + `dialect: xfyun-tts` 直连在线语音合成 v2/tts WebSocket:mp3/wav、语速/音高/音量、川粤多方言音色;与 iat 共用同一应用三件套凭证,单次超 8000 字节文本自动分段多次调用 |
| 混搭 | ASR、TTS、实时转录三个选择器互相独立:可百炼批量识别 + 本地合成 + Deepgram 实时 |
| 模型改名自愈 | 编辑器内置「拉取平台最新模型」按钮(OpenAI/Groq/硅基流动/百炼通吃 `/models` 接口)+ 静态预设标注已下线模型 + 自由手填三层兜底 |
| 密钥安全 | 两种方式任选:**页面直接填写**(`apiKey` 内联,保存热生效,持久化在 `~/.dsh/settings.yaml`,接口与页面只回显掩码 `sk-***xxxx`,编辑时留空即沿用、可显式清除);或 **环境变量名引用**(`apiKeyEnv` 等,值放 `~/.dsh/.env`)。内联值优先于环境引用 |
## 快速开始
```sh
# 1. 安装官方 dsh(已装可跳过)
npm install -g @deepseek-ai/dsh
# 2. 安装本插件(从 GitHub,一条命令)
dsh plugin --profile web add github:agent-mobile/dsh-speech
# 首次安装 pnpm 会询问是否允许构建本包(allowBuilds),按 dsh 的提示放行即可;
# 开发调试: dsh plugin --profile web add link:<本仓库路径>
# 3. 配置(见下),随 dsh web 启动自动生效
dsh web
```
**桌面入口**:插件带一个浏览器半区,安装后 dsh web 的
「设置 → 插件」里会出现「**语音服务**」标签页(与「插件配置」并列),
原生渲染提供商配置界面。配置项 `uiEntry: false` 可隐藏该标签页。
`/s/` 独立页面仍可直接访问(手机 App 入口依赖它)。
两个管理界面都内置**平台预设**(`src/presets.ts`,单一数据源):
添加 `openai-compatible` 提供商时选择「阿里云百炼 / OpenAI / Groq /
硅基流动」即可一键填入 Base URL、密钥环境变量名、推荐模型与音色
(模型/音色仍可自由输入,未列出的网关走「自定义」);`streaming-ws`
切换方言时自动补全云端地址与鉴权环境变量名。密钥值本身永远不进配置
与页面——只填环境变量名,值放在 `~/.dsh/.env` 或启动环境,重启生效。
与 [dsh-mobile-gateway](../dsh-mobile-gateway) 一起装时,端口绑定由 mobile-gateway
负责(`0.0.0.0`),本插件的路由自动对局域网可达。
## 配置
token 必填,其余有默认值。配置写在 profile patch
(`~/.dsh/profiles/web/cordis.patch.yml`)的插件行:
```yaml
- id: speech
config:
token: 换成一个长随机串
transcriptionProvider: '' # 空 = 自动(恰好一个可用时选中)
synthesisProvider: ''
sessionTranscriptionProvider: '' # 实时转录选择器,语义同上
maxAudioUploadBytes: 26214400 # 单次音频上传上限(字节)
maxSynthesisChars: 4000 # 单次合成文本上限(字符)
providers:
# 云端:任何 OpenAI 兼容端点
groq-asr:
type: openai-compatible
baseUrl: https://api.groq.com/openai/v1
apiKeyEnv: GROQ_API_KEY
asrModel: whisper-large-v3-turbo
siliconflow-tts:
type: openai-compatible
baseUrl: https://api.siliconflow.cn/v1
apiKeyEnv: SILICONFLOW_API_KEY
ttsModel: FunAudioLLM/CosyVoice2-0.5B
ttsVoice: FunAudioLLM/CosyVoice2-0.5B:alex
# 本地:自建 SenseVoice + CosyVoice2(契约已在 OpenClaw relay 验证)
local:
type: local-relay
asrEndpoint: http://192.0.2.10:9001
ttsEndpoint: http://192.0.2.10:9002
ttsVoice: 中文女
diarization: true # 开启实时转录的说话人分离(include_embedding)
# 云端流式实时转录:无本地服务的用户
deepgram:
type: streaming-ws
dialect: deepgram
url: wss://api.deepgram.com/v1/listen
apiKeyEnv: DEEPGRAM_API_KEY
diarization: true
# 国内云流式(单人听写,60s 连接上限自动轮转)
xfyun:
type: streaming-ws
dialect: xfyun-iat
url: wss://iat-api.xfyun.cn/v2/iat
appIdEnv: XF_APP_ID
apiKeyEnv: XF_API_KEY
apiSecretEnv: XF_API_SECRET
# 国内云流式(长音频/多人,说话人分离;与 iat 共用应用凭证但需在控制台单独开通)
xfyun-rtasr:
type: streaming-ws
dialect: xfyun-rtasr
url: wss://rtasr.xfyun.cn/v1/ws
appIdEnv: XF_APP_ID
apiKeyEnv: XF_API_KEY
diarization: true # 开启 roleType=2 角色分离(结果 rl 字段 → spk)
# 国内云合成(TTS;与 iat 共用三件套凭证但需在控制台单独开通,方言音色需先添加发音人)
xfyun-tts:
type: streaming-ws
dialect: xfyun-tts
url: wss://tts-api.xfyun.cn/v2/tts
appIdEnv: XF_APP_ID
apiKeyEnv: XF_API_KEY
apiSecretEnv: XF_API_SECRET
ttsVoice: xiaoyan # 发音人 vcn,必填;方言音色以控制台显示为准
ttsFormat: mp3 # mp3(默认)/ wav
# 本地流式(想要逐字 partial 时)
funasr:
type: streaming-ws
dialect: funasr
url: ws://192.0.2.10:10095
```
### 字段说明
| 字段 | 默认 | 说明 |
| --- | --- | --- |
| `token` | (必填) | 客户端以 `Authorization: Bearer <token>` 呈现;留空插件拒绝启动 |
| `transcriptionProvider` / `synthesisProvider` / `sessionTranscriptionProvider` | `''` | 分别钉选批量识别 / 合成 / 实时转录的 entry id;空时恰好一个可用者自动选中,多个可用报 `SPEECH_PROVIDER_AMBIGUOUS` |
| `maxAudioUploadBytes` | `26214400` | `/s/api/speech.transcribe` 请求体上限 |
| `maxSynthesisChars` | `4000` | `/s/api/speech.synthesize` 文本长度上限 |
### provider entry
**`openai-compatible`**(云端与兼容网关):
| 字段 | 说明 |
| --- | --- |
| `baseUrl` | 兼容 API 根(含 `/v1`) |
| `apiKey` | 页面直接填写的密钥值(内联优先);任何接口响应不回显,只报掩码 |
| `apiKeyEnv` | 环境变量名;缺省 = 无鉴权(内网网关)。变量为空时该 provider 视为不可用 |
| `asrModel` | `/audio/transcriptions` 的 model;不配则该 entry 不提供转写 |
| `asrLanguage` | 默认语言提示(请求头可覆盖) |
| `ttsModel` / `ttsVoice` / `ttsFormat` / `ttsSpeed` | `/audio/speech` 参数;不配 `ttsModel` 则不提供合成 |
| `timeoutMs` | 上游超时,默认 60000 |
**`dashscope`**(阿里云百炼原生协议 · 批量识别/合成 + 句级实时转录):
| 字段 | 说明 |
| --- | --- |
| `apiKey` / `apiKeyEnv` | 同 `openai-compatible`(页面直填或环境变量名,内联优先) |
| `asrModel` | 百炼批量识别模型(如 `qwen-audio-3.0-asr-flash`);不配则不提供识别 |
| `asrLanguage` | 语言提示(`zh`/`en`/…),映射到 `language_hints` |
| `ttsModel` | 百炼非实时合成模型(如 `qwen3-tts-flash`);不配则不提供合成 |
| `ttsVoice` | **必填**(配了 ttsModel 时):音色名(如 `Cherry`) |
| `baseUrl` | API 根,默认 `https://dashscope.aliyuncs.com` |
| `vadSilenceMs` / `vadMaxSpeechMs` / `vadMinSpeechMs` / `partialFlushMs` | 实时转录 VAD 调参(句级 + 伪流式逐字预览) |
| `timeoutMs` | 上游超时,默认 60000 |
> 模型改名?编辑器内置「拉取平台最新模型」按钮(GET `/compatible-mode/v1/models`),或手动填入控制台模型广场的最新名称——无需改代码。
> **能力边界(写文档必读)**:百炼的实时转录**不支持说话人分离**。其实时/批量 ASR 的
> `sentence` 结果只含 `begin_time/end_time/text/sentence_id/words[]`,没有任何 speaker 字段
> (官方文档 `fun-asr-server-events` 已确认)。因此 `dashscope` 适配器的
> `sessionTranscription.diarization` 恒为 `false`,所有语句统一归 spk=0。这是百炼服务本身
> 的能力边界,不是插件适配器的限制——真正的声纹说话人聚类只有 `local-relay`
> (SenseVoice `include_embedding`)提供。
**`local-relay`**(自建 SenseVoice / CosyVoice2):
| 字段 | 说明 |
| --- | --- |
| `asrEndpoint` | ASR 服务根(`POST {endpoint}/transcribe`);不配则不提供转写。配了即同时提供实时转录(VAD 句级模式) |
| `ttsEndpoint` | TTS 服务根:批量 `POST {endpoint}/synthesize`;流式 `WS {endpoint}/ws/tts`(增量出声,需 TTS 服务带该 WebSocket 端点,连接失败自动回退批量);不配则不提供合成 |
| `ttsVoice` | 默认音色(请求体可覆盖) |
| `asrTargetSampleRateHz` | ASR 目标采样率,默认 16000(自动重采样/降混) |
| `diarization` | 实时转录开启说话人分离:冲刷时带 `include_embedding`,服务端返回 `segments[{spk,text,embedding}]` 时做会话级聚类 |
| `vadSilenceMs` / `vadMaxSpeechMs` / `vadMinSpeechMs` | VAD 调参;默认 700/15000/300(开 diarization 时静音与上限自动变为 900/8000,长句自动分段) |
| `spkMergeThreshold` | 说话人聚类余弦阈值,默认 0.5 |
| `partialFlushMs` | **伪流式逐字预览**:说话中每隔该毫秒把已积累音频重新识别并作为 partial 预览下发(灰字),句末仍用完整音频定稿;0 关闭。默认 1500 |
| `timeoutMs` | 上游超时,默认 60000:批量是整个请求的硬上限,流式是「连接 + 相邻两帧」的空闲上限(健康的长合成不会被掐断) |
**`streaming-ws`**(流式实时转录上游 / 讯飞在线合成):
| 字段 | 说明 |
| --- | --- |
| `dialect` | 信令方言:`deepgram` / `funasr` / `sherpa` / `xfyun-iat` / `xfyun-rtasr`(实时转录)/ `xfyun-tts`(合成,不提供转录) |
| `url` | 上游 WebSocket 地址(`ws://` / `wss://`) |
| `apiKey` / `appId` / `apiSecret` | 页面直接填写的凭证(内联优先;密钥值不回显)。讯飞 iat / tts 需三件套;**rtasr 只需 APP ID + API Key**(HmacSHA1 签名,无 Secret) |
| `apiKeyEnv` | Deepgram `Token` 鉴权 / 讯飞 API Key 的环境变量名 |
| `appIdEnv` / `apiSecretEnv` / `rotateAfterSec` | 讯飞 iat 三件套与轮转秒数(默认 55,避开 60s 连接上限,自动无缝续接);rtasr 用不到后两项,tts 用不到 `rotateAfterSec` |
| `language` | 默认语言提示。rtasr 原样透传为 `lang` 参数(`cn` / `en` / `cn_cantonese` …),缺省普通话;粤语等方言需先在控制台「实时语音转写-方言/语种」为该应用开通 |
| `diarization` | 请求说话人分离(Deepgram diarize / 讯飞 rtasr `roleType=2`,`rl` 角色编号映射为会话内 spk;其余方言忽略) |
| `ttsVoice` | xfyun-tts 发音人(上游 `vcn`),**该方言下必填**——API 拒绝无发音人请求;方言音色(粤语等)需先在控制台添加发音人,名字以控制台显示为准 |
| `ttsFormat` / `ttsSpeed` / `ttsPitch` / `ttsVolume` | xfyun-tts:容器 `mp3`(默认)/ `wav`(raw PCM 套 RIFF 头);语速/音高/音量 0–100,缺省均 50 |
> **xfyun-rtasr 协议要点**:端点 `wss://rtasr.xfyun.cn/v1/ws`,查询参数
> `appid` / `ts` / `signa`(`signa = base64(HmacSHA1(MD5(appid+ts), apiKey))`),连接后直接发
> 16kHz PCM16 二进制帧(无起始帧),结束发二进制 `{"end": true}`。上游在 **15 秒无音频**时
> 主动断连(错误码 37005),适配器每 10s 静音间隙注入 40ms 静音保活,并把句子时间戳
> 按已注入量回拨到客户端时间轴。与 iat 相比:单连接无 60s 上限、逐句 draft/final、
> 支持角色分离——**需在讯飞控制台为同一应用单独开通「实时语音转写」服务**。
> **xfyun-tts 协议要点**:端点 `wss://tts-api.xfyun.cn/v2/tts`,鉴权与 iat 同一套
> HMAC-SHA256 签名(`host` / `date` / `authorization` 查询参数,APISecret 参与签名)。
> 每次调用一个连接:发送单帧 JSON(`common.app_id` + `business.vcn/aue/sfl/...` +
> `data.text` base64),上游以 `data.audio` base64 片段流式回传,`data.status === 2`
> 为结束;单次文本上限约 8000 utf8 字节(~2000 汉字),超限由适配器按字符边界分段、
> 顺序多次调用并拼接音频。`aue=lame`(mp3,配 `sfl=1`)或 `raw`(PCM16 16kHz 单声道,
> 适配器套 WAV 头)。**需在讯飞控制台为同一应用单独开通「在线语音合成」服务**。
**讯飞凭证获取(xfyun-iat / xfyun-tts 三件套)**:在 [讯飞开放平台控制台](https://console.xfyun.cn/) 获取——
1. 注册并登录讯飞开放平台,完成实名认证(个人认证即可)
2. 左侧菜单「我的应用」→「创建应用」,填写应用名称,能力勾选**语音听写(IAT)**;用 rtasr / tts 的话在应用详情页再分别开通**实时语音转写** / **在线语音合成**(同一应用内各服务独立开通、独立计费)
3. 创建后进入该应用详情页,直接显示 **APP ID**、**APIKey**、**APISecret**(APISecret 默认隐藏,点「显示/复制」查看;丢失可在该页重置)
新用户有免费体验额度(控制台「资源/用量」可查剩余量);环境变量模式下三个值分别写入 `~/.dsh/.env` 的 `XF_APP_ID` / `XF_API_KEY` / `XF_API_SECRET`。
## HTTP API(`/s/api`)
**鉴权规则(与 mobile-gateway 的 `/m/` 管理页同款)**:
- **本机访问**(`127.0.0.1` / `localhost` 打开 `http://127.0.0.1:3080/s/`)**免 token**——桌面浏览器直接打开即用
- **局域网访问**(手机等)需 `Authorization: Bearer <token>` 头或 `?token=<secret>` 查询参数
| 方法 | 路径 | 请求 | 响应 |
| --- | --- | --- | --- |
| POST | `/s/api/speech.transcribe` | 音频原始字节做 body,`Content-Type` 标明容器(`audio/wav` 等),可选 `X-Speech-Language` 头 | `{"text":"...","provider":"local"}` |
| POST | `/s/api/speech.synthesize` | `{"text":"...","voice?":"...","format?":"mp3"\|"wav"}` | 音频字节(`Content-Type: audio/wav` / `audio/mpeg`,`X-Speech-Provider` 标明提供商) |
| GET | `/s/api/speech.providers` | — | 选择快照:候选、钉选、接受的格式、音色、实时转录能力(mode/句级或逐字/diarization,不含任何密钥) |
| GET | `/s/api/health` | — | `{"status":"ok"}` |
错误响应统一为 `{"error":{"code":"...","message":"..."}}`,状态码:
401 未授权 / 400 请求形状错误 / 413 超限 / 415 格式不支持 /
502 上游失败 / 503 无可用或多个可用未钉选的 provider。
错误码全集:`SPEECH_BAD_REQUEST` `SPEECH_UNAUTHORIZED` `SPEECH_AUDIO_TOO_LARGE`
`SPEECH_TEXT_TOO_LONG` `SPEECH_UNSUPPORTED_FORMAT` `SPEECH_PROVIDER_UNAVAILABLE`
`SPEECH_PROVIDER_AMBIGUOUS` `SPEECH_PROVIDER_CONFIGURED_MISSING`
`SPEECH_PROVIDER_CONFIGURED_UNAVAILABLE` `SPEECH_UPSTREAM_FAILURE` `SPEECH_INTERNAL`。
### `/s/ws` 实时转录通道(WebSocket)
鉴权与 `/s/api` 同款:本机(loopback Host)免 token,局域网用
`?token=<secret>` 查询参数或 `Authorization` 头。JSON 文本帧为控制消息,
二进制帧为音频(PCM16 LE,按 `session.create` 声明的采样率):
```
C→S {"type":"session.create","provider":null,"sampleRateHz":16000,
"encoding":"pcm16","diarization":true}
S→C {"type":"session.ready","provider":"local","mode":"vad","partial":false,
"diarization":true} ← 能力协商:句级/逐字/说话人分离
C→S <二进制 PCM16 帧,任意分块>
S→C {"type":"partial","text":"…"} ← 仅 streaming 模式
S→C {"type":"transcript","text":"…","segments":[{"spk":0,"text":"…",
"startMs":0,"endMs":1200}]} ← spk 会话级稳定
C→S {"type":"session.close"}
S→C {"type":"closed"} ← 随后连接关闭
S→C {"type":"error","code":"…","message":"…"} ← 随后连接关闭
```
`provider` 可为空(走服务端 `sessionTranscriptionProvider` 选择器)或按连接
临时钉选。`/s/` 配置页的「实测 6 秒」按钮就是这条通道的浏览器端演练
(getUserMedia → 时间线事件回放)。
Dart SDK 侧:`DshSpeechClient.openSession()` 返回 `DshSpeechSession`
(`events` 广播流 + `sendAudio`/`close`),配套 App 的
`TranscriptionController` 状态机(开始/暂停/恢复/停止/失败重试)。
### curl 示例
```sh
# 识别一段 WAV
curl -s http://192.168.1.5:3080/s/api/speech.transcribe \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: audio/wav" \
--data-binary @speech.wav
# 合成并播放
curl -s http://192.168.1.5:3080/s/api/speech.synthesize \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"text":"你好,世界"}' -o out.mp3
```
## 安全
- 全路由常数时间 Bearer 比较;缺/错 token 统一 401
- token 留空时插件 fail-loud 拒绝启动
- 密钥两种存放方式:内联(页面填写,保存在 `~/.dsh/settings.yaml`)或环境变量;
**任何 GET 响应与页面永不回显密钥值**——内联只报 `sk-***xxxx` 掩码与"已保存"状态;
编辑提供商时密钥框留空即沿用已存密钥(`*Keep` 语义),勾选"清除"才删除
- 音频字节与文本长度双上限;`local-relay` 端点应只在内网使用(不要把
SenseVoice/CosyVoice2 的地址暴露到公网)
## 设计说明
- HTTP 路由 + `/s/ws` WebSocket 升级路由都挂在 webServer 扩展点;**不碰 `/api` 与 `/m/api`**,卸载即消失
- 实时转录的 `vad` 模式把 OpenClaw SenseVoice provider 的分段状态机
(RMS 静音判停 + 说话人质心聚类)移植到主机侧,PCM16 直入、不再经过
G.711 μ-law 折返;`streaming` 模式逐字 partial、说话人分离由上游承担
(Deepgram diarize)或自动轮转续接(讯飞 60s 上限)
- 语音产物不进 session log:文字仍走官方 `session.prompt`,天然满足 harness
"模型可见 ⟺ 已记录" 的约定
- 采样率/声道归一化(48k 立体声 → 16k 单声道线性插值)在 local-relay adapter
内完成,App 端录制 16kHz 单声道即零转码直通
## 测试
```sh
pnpm test # 58 项:gate / wav / providers / selection / vad / session-channel / integration
pnpm typecheck
pnpm run build
```
集成测试以真实 WebServer + 真实本机 HTTP/WS mock 上游(模拟 SenseVoice
diarized 响应、Deepgram 事件流、OpenAI 兼容端点)覆盖全链路,包括鉴权、
上限、错误码、provider 选择策略与实时会话协商/转录/关闭。
Install
dsh plugin --profile web add github:agent-mobile/dsh-speech
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-speech from the hub
- This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.