🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

语音朗读 zerotoo1/dsh-anima-vi

DeepSeek Harness
zerotoo12 天前有提交已提供中文文档扫描:含敏感能力

为助手消息添加朗读按钮,可调音色语速并显示频谱

30/100
综合分
0
GitHub 星标
—
npm 周下载
—/5
用户评分

数据截至 2026/11/2 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。

安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add zerotoo1/dsh-anima-vi
该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:仅索引
是什么
dsh 原生插件 · vision
装得上吗
本站尚未做安装检查
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 2 天前
档位由哪些信号合成

本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。

验证判据与等级说明 →
社区实装还没有人打卡 · 装过的第一个就是你
安全扫描自动 · 静态分析源码
v2.7.0
🔵 扫描:含敏感能力尚无人工评估
展开查看扫描说明与证据 ↓

静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。

读写/删除本地文件发起外部网络请求
查看扫描证据(共 36 处,展示前 28 条)
  • 读取本地文件
    lib/index.js:155
    info = await stat(file);
  • 读取本地文件
    lib/index.js:187
    const stream = createReadStream(file, { start, end });
  • 读取本地文件
    lib/index.js:204
    const stream = createReadStream(file);
  • 发起网络请求
    lib/client.js:1823
    var response = await fetch(SPEAK_PATH + '?' + query, {
  • 发起网络请求
    lib/client.js:2576
    fetch(CONFIG_PATH, { headers: { Accept: 'application/json' } })
  • 发起网络请求
    lib/client.js:2850
    var response = await fetch(PREVIEW_PATH, {
  • 发起网络请求
    lib/client.js:2865
    response = await fetch(PREVIEW_PATH + '?' + query.join('&'), {
  • 读取本地文件
    lib/host-settings.js:124
    for (const entry of readdirSync(profilesDir, { withFileTypes: true })) {
  • 读取本地文件
    lib/host-settings.js:204
    if (existsSync(join(directory, 'package.json'))) return directory;
  • 读取本地文件
    lib/host-settings.js:230
    const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
  • 读取本地文件
    lib/host-settings.js:240
    if (existsSync(absolute)) return absolute;
  • 读取本地文件
    lib/synth.js:605
    const info = await stat(path);
  • 写入本地文件
    lib/synth.js:620
    await rename(from, to);
  • 写入本地文件
    lib/synth.js:622
    await copyFile(from, to);
  • 删除本地文件
    lib/synth.js:623
    await rm(from, { force: true });
  • 写入本地文件
    lib/synth.js:771
    await mkdir(tempDir, { recursive: true });
  • 写入本地文件
    lib/synth.js:778
    await writeFile(textFile, text, 'utf8');
  • 读取本地文件
    lib/synth.js:830
    const log = await readFile(logFile, 'utf8').catch(() => '');
  • 删除本地文件
    lib/synth.js:850
    await rm(textFile, { force: true }).catch(() => {});
  • 删除本地文件
    lib/synth.js:851
    await rm(mediaFile, { force: true }).catch(() => {});
  • 删除本地文件
    lib/synth.js:852
    await rm(logFile, { force: true }).catch(() => {});
  • 写入本地文件
    lib/synth.js:960
    await mkdir(tempDir, { recursive: true });
  • 写入本地文件
    lib/synth.js:976
    await writeFile(mediaFile, bytes);
  • 删除本地文件
    lib/synth.js:987
    await rm(mediaFile, { force: true }).catch(() => {});
  • 删除本地文件
    lib/synth.js:1030
    await rm(mediaFile, { force: true }).catch(() => {});
  • 读取本地文件
    lib/synth.js:1334
    return { key, file, bytes: (await stat(file)).size, cached: true, voice };
  • 读取本地文件
    lib/synth.js:1340
    return { key, file, bytes: (await stat(file)).size, cached: true, voice };
  • 读取本地文件
    lib/synth.js:1356
    return { key, file, bytes: (await stat(file)).size, cached: false, voice };
扫描于 2026/11/3(0 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个

DeepSeek Harness 网页界面的朗读扬声器按钮和语音设置卡片:支持 edge-tts、任意 OpenAI 兼容端点、Yandex SpeechKit 和 Google Cloud TTS,带实时频谱可视化。

README

由 DeepSeek 最新模型翻译生成
dsh-voice-v DeepSeek Harness 网页 GUI 里,每条助手消息下方都有一个朗读按钮,另外在设置 → 插件里还有一张设置卡片。点按钮,就用 edge-tts 朗读这条消息;卡片里可以配置音色、预设、语速、音调、发音词典、按钮内的可视化效果,以及是否朗读代码——全程不用碰 package.json 或 cordis.patch.yml。 - 七种音色——两种原生俄语音色(ru-RU-SvetlanaNeural、ru-RU-DmitryNeural),外加五种多语音色,读俄语带一点口音。部署时可以通过 config.voices 扩充音色目录。 - 预设跟着消息的语气走——「блин, устала」读成疲惫,「люблю, скучаю」读成鲜活,技术回答读成平静,调情读成放肆(lib/tone.js,纯规则,不用模型,不额外占上下文)。 - 朗读时有实时可视化——五种样式:从按钮内真实的频谱分析器和示波器,到输入框上方绘制的宽波形,再加两种纯 CSS 样式;在卡片里可切换,也可关掉。 - 自定义预设——把滑块存成预设,和内置预设放在一起,不需要时删掉。
展开完整文档(README 剩余 1 节)
dsh-voice-v

DeepSeek Harness 网页 GUI 里,每条助手消息下方都有一个朗读按钮,另外在设置 → 插件里还有一张设置卡片。点按钮,就用 edge-tts 朗读这条消息;卡片里可以配置音色、预设、语速、音调、发音词典、按钮内的可视化效果,以及是否朗读代码——全程不用碰 package.json 或 cordis.patch.yml。

- 七种音色——两种原生俄语音色(ru-RU-SvetlanaNeural、ru-RU-DmitryNeural),外加五种多语音色,读俄语带一点口音。部署时可以通过 config.voices 扩充音色目录。
- 预设跟着消息的语气走——「блин, устала」读成疲惫,「люблю, скучаю」读成鲜活,技术回答读成平静,调情读成放肆(lib/tone.js,纯规则,不用模型,不额外占上下文)。
- 朗读时有实时可视化——五种样式:从按钮内真实的频谱分析器和示波器,到输入框上方绘制的宽波形,再加两种纯 CSS 样式;在卡片里可切换,也可关掉。
- 自定义预设——把滑块存成预设,和内置预设放在一起,不需要时删掉。

仓库:<https://github.com/zerotoo1/dsh-anima-vi>

- 宿主端(lib/index.js)在注入的 webServer Cordis 服务上注册 /dsh-voice-v 前缀路由,用 edge-tts 命令行合成语音,并注册 dsh-voice-v 设置命名空间(lib/host-settings.js)。消息的预设可以根据措辞的语气来选(lib/tone.js)。
- 浏览器端(lib/client.js)注册两个插槽:朗读按钮放在 conversation.chat.assistant-actions(就是「赞/踩」按钮所在的那一条),设置卡片放在 plugins.bundle.config,以插件的包名为键。卡片通过 configForms 服务读写 dsh-voice-v 命名空间,而且只在宿主提供该命名空间时读写。表单服务在自己的 fiber 里等待,卡片通过 configForms.whileServed 挂载——所以只要宿主开始提供该命名空间,卡片就会出现,而不只是在插件恰好先于宿主的设置文档挂载时才出现。

环境要求

| Node | ^22.19.0 或 >=24(harness 运行时) |
| edge-tts | Python 包 edge-tts(7.x)的命令行——插件唯一运行的外部程序 |
| 网络 | edge-tts 要连微软语音服务;~/.dsh/cache/dsh-voice-v 里的 MP3 缓存能省掉重复请求 |
| npm 包 | 无 —— 插件没有声明 dependencies;浏览器端只需要 react,由 harness 外壳提供 |

edge-tts 路径是部署层面的关键:cordis.patch.yml 里的 binPath。默认值是裸名 edge-tts,通过 PATH 解析——pip install edge-tts 就装在这里——所以仓库里不带任何机器相关的路径。DSH_VOICE_V_EDGE_TTS 可为单次运行覆盖默认值;如果部署把命令行放在别处,就设置 binPath。

设置放在哪里

| 层 | 位置 | 谁来编辑 |
| 内置默认值 | lib/config.js(DEFAULT_PRESETS、DEFAULT_RATE 等) | 没人 |
| 组合层 | cordis.patch.yml 里插件行的 config | 部署方 |
| 用户层 | 用户设置文档,命名空间 dsh-voice-v | 设置卡片 |

解析顺序是 用户段 → 插件行 config → 内置默认值;同一层内则是 显式 rate / pitch → 命名 preset → 该层自己的默认值。

命名空间在 Host 上注册,带 schemastery schema,并以插件行的 config(投影到命名空间字段)作为 base,这就是 DSH 文档化的语义:用户清空的字段会重新继承组合层,而不是继承当前值的副本。

两端共用的名字

| 项 | 值 |
| 设置命名空间 | dsh-voice-v |
| 设置卡片插槽 | plugins.bundle.config(以包名 dsh-voice-v 为键) |
| 注入的客户端服务 | slots、configForms |
| Host 服务 | ctx.settings(SettingsForms);host 端发布一个 volatile 的 schemastery Config |

命名空间只能由 Host 插件注册,而设置卡片只对 Host 实际提供的命名空间渲染——所以两端和这张表写的是同一个字符串。

设置卡片

设置 → 插件 → 插件配置 → Озвучка ответов (dsh-voice-v)。

* Язык интерфейса / Interface language —— 卡片本身是双语的。下拉框提供 Русский 和 English,值就是 uiLanguage 设置(默认 ru),切换后整张卡片、消息下方的朗读按钮及其提示文字会立刻重新标注——无需刷新。选择和其他字段一样先暂存:卡片立即用暂存的语言显示,Сохранить 写入,Отменить правки 恢复 Host 的值。朗读不受影响:语音读的是回答,不是界面。Host 数据从不翻译——语音 id 在两种语言下都保持 ru-RU-SvetlanaNeural。
* 预设 —— 内置五个,以数据形式放在 lib/config.js,按 Host 在 GET /dsh-voice-v/config 上报的内容渲染:

| id | rate | pitch |
| zhivoy(默认) | +6% | +12Hz |
| neytralnyy | 0 | 0 |
| spokoiny | −6% | −8Hz |
| derzkiy | +10% | +6Hz |
| ustaly | −12% | −15Hz |
选择预设会立刻移动滑块,并清除任何显式的 tempo/tone 覆盖。部署方可以在 config.presets 下添加自己的预设;它会出现在卡片里,标签就是它的 id。

* Свои пресеты — 一个名称字段和 Сохранить как пресет 按钮,把滑块当前显示的内容(tempo、tone,以及卡片持有的音色)做成预设。用户输入的名称就是标签;背后的 id 由该名称派生(user-…),所以同一个名称永远对应同一个预设,也绝不会遮蔽五个内置预设中的任何一个。用户预设和内置预设并排列在列表里,并带一个 ✕ 按钮——只有用户预设才有这个删除控件。两个操作都只暂存一次 userPresets 写入,携带整个预设池,其他暂存的编辑保持不变。

用户预设可以自带音色。用户没有显式设置音色时,就用它;显式的 voice 仍然优先,和显式的 rate / pitch 压过预设完全一样。

* Sliders — tempo −30% … +40%,pitch −40 … +40 Hz,步长 1,旁边显示数值。取值范围来自宿主(rateRange / pitchRange),不是来自 bundle。
* Сбросить к пресету — 暂存对 rate 和 pitch 的取消设置,保存后具名预设重新生效。
* Прослушать — 完全按滑块显示的内容合成,无论是否保存,走 POST /dsh-voice-v/preview,并立即播放 MP3。预览从不读取、也从不写入设置文档。
* Словарь произношения — 卡片底部的折叠块。它只列出用户自己拥有的词条:内置表留在宿主里(dictionaryDefaults),从不渲染,所以这个块是短列表,而不是一百五十行。两个字段加一个按钮:词条一添加就写入设置——不用回到卡片自己的 Save,那个按钮保持未激活。编辑某一行的读音会在失焦时写入,Удалить 把词条从用户层移除,内置词条于是回落到内置读音。每行都有自己的 ▶ 按钮,试听该条转写。
* Вырезать код из озвучки — 见下文。
* Пресет по тону сообщения — 见下文「The preset by the tone of the message」。默认开启。
* Голос — 下拉框,选项来自宿主上报的目录(voiceList):edge-tts 为俄语提供的两个俄语音色,以及五个能正确朗读俄语的多语音色。最后一项 «Свой голос…» 把下拉框换成文本框,任何 id 仍可手动输入。目录之外的音色会单独显示为一项:下拉框从不隐藏正在使用的音色。
* Визуализация — 见下文「The visualization inside the message button」。默认开启。
* Синтез речи — 服务商下拉框(默认 edge-tts,OpenAI 兼容端点,Yandex SpeechKit 或 Google Cloud TTS),以及,当选中其中一个后,它自己的字段:endpoint 的 base URL、模型和密钥,Yandex 和 Google 的语音和密钥。见下文「自定义 TTS 服务商」。
* Сохранить / Отменить правки — 在保存之前不会写入任何内容:卡片暂存用户改动过的字段,每个字段发送一次 scope.set(或 scope.unset)。标题栏的提示会标出未保存的改动,只读内存模式下的命名空间会禁用这些控件。

语音目录

目录是宿主侧的数据(lib/config.js 中的 VOICE_OPTIONS),由 GET /dsh-voice-v/config 以 voiceList 的形式上报给卡片。插件包自身不硬编码任何语音:宿主上报什么,下拉框就提供什么。

| 分组 | id | 说明 |
| Русские | ru-RU-SvetlanaNeural | 默认 |
| Русские | ru-RU-DmitryNeural | 服务提供的唯一另一个俄语语音 |
| Мультиязычные | en-US-AvaMultilingualNeural | 能正确朗读俄语 |
| Мультиязычные | en-US-EmmaMultilingualNeural | 能正确朗读俄语 |
| Мультиязычные | de-DE-SeraphinaMultilingualNeural | 能正确朗读俄语 |
| Мультиязычные | fr-FR-VivienneMultilingualNeural | 能正确朗读俄语 |
| Мультиязычные | pt-BR-ThalitaMultilingualNeural | 能正确朗读俄语 |

旧的俄语女声(Dariya、Dasha、Polina、Zhanna、Masha)已被服务下线,现在会以 NoAudioReceived 失败,因此不再提供。

部署可以通过插件行的 config.voices 扩展或替换目录——与 presets 键用的是同一套模式:

- insert:
- id: dsh-voice-v
name: 'dsh-voice-v'
config:
voices:
- de-DE-KatjaNeural # a new voice joins the catalogue
- { id: en-US-JennyNeural, label: 'Дженни' }
- { id: ru-RU-SvetlanaNeural, label: 'Света' } # replaces one entry
voices: { replace: [ru-RU-DmitryNeural] } # replaces the catalogue

用户层永远不拥有目录:只有 voice 字段本身是用户设置,目录不认识的语音也照样会被采用。

自定义 TTS 服务商

edge-tts 是默认值,也一直是默认值。插件与本地 CLI 通信,不需要密钥,也不需要网络;什么都不配置的部署,行为与本节出现之前完全一样——同样的进程、同样的参数、同样的缓存。

有自己的密钥的部署,可以把插件指向 OpenAI 兼容 endpoint。卡片会多出一组 «Синтез речи»,含四个字段:

| 字段 | 含义 |
| ttsProvider | edge(默认)、custom、yandex、google 或 google-free |
| customBaseUrl | base URL,例如 https://api.openai.com/v1 |
| customApiKey | 服务商的密钥——机密 |
| customModel | 模型 id,例如 tts-1 |
| yandexApiKey | Yandex SpeechKit 密钥——机密 |
| yandexVoice | Yandex 语音:marina、ermil、pavel、male、jane |
| googleApiKey | Google Cloud API key,属于机密 |
| googleVoice | Google 语音:ru-RU-Wavenet-A … ru-RU-Standard-B |

只有选中 custom 时,端点自己的字段才会出现;只有选中 yandex 时,Yandex 的字段才会出现;只有选中 google 时,Google 的字段才会出现。它们都是普通设置:归用户层所有,会覆盖部署层,和其他所有设置一样。部署层可以在插件行的 config(cordis.patch.yml)里预填这些字段,但 key 应当放在配置档的用户层,绝不能进仓库。

Yandex 服务商

Yandex SpeechKit 单独拿出来说,因为它不是 OpenAI 兼容端点:它有自己的 REST 形态。

POST https://tts.api.cloud.yandex.net/tts/v3/utteranceSynthesis?voice=<id>&format=mp3&sampleRate=48000&speed=<x>
Authorization: Api-Key <yandexApiKey>
X-DataLoggin-Enabled: false
{"text": "...", "hints": [{"voice": "<id>", "speed": 1.06, "pitchShift": 12, "volume": 1.1}], "outputAudioSpec": {"containerAudio": {"containerAudioType": "MP3"}}}

路由就是服务自己的描述符里写的那条:同一主机的 gRPC 反射描述了服务 speechkit.tts.v3.Synthesizer 及其 rpc UtteranceSynthesis,其 http 规则是 POST /tts/v3/utteranceSynthesis,body 为 。旧的 /tts/v3/voiceSynthesis 路由不在该描述符里,会返回 404 和纯文本 "Not Found"——那是 v2 的形态,是猜的。

hints 是数组——服务用 Go 的 JSON 解码器读 body,那里传对象会被拒绝("cannot unmarshal object into Go value of type []json.RawMessage");
* 语速以普通乘数传递(+6% → 1.06,1.0 → 1,-5% → 0.95);没人配置语速时,完全不发送 speed;
* 音调和音量在同一组 hint 里传递——pitchShift 以赫兹为单位(+12Hz → 12),volume 以乘数表示(+10% → 1.1)。两者都由服务自己的描述符命名,且都会被夹到服务的取值范围内(音调 ±40 Hz,乘数 0.25–4);
* key 生效的文件夹是查询参数(folderId),绝不是 body 字段;
* 该 rpc 是服务端流式,所以答案是若干 chunk,解析器会从音频可能出现的每种形态里取出音频——单个 chunk(audioChunk.data)、旧的 result.audioChunk.data 信封、chunk 数组、按行分隔的 chunk、带自身类型的 chunk——按到达顺序拼接;
* 字节会解码进其他所有服务商都会写入的同一个 MP3 缓存,所以 /speak、音频路由和卡片都分辨不出服务商。没设 key 时,完全不使用该服务商,仍由本地命令行负责。
* 非 2xx 应答会失败,错误消息会写明状态码和服务自己的错误文本。403(或响应体里提到 PermissionDenied)直白地说明密钥对这个文件夹没有权限,必须给它授予 SpeechKit 角色。密钥永远不会出现在错误消息里——服务自己生成的文本里也会把它抹掉。

Google 服务商

Google Cloud Text-to-Speech 单独讲,原因相同:它有自己的 REST 结构,也有自己对音色和音调的理解。

POST https://texttospeech.googleapis.com/v1/text:synthesize?key=<googleApiKey>
Content-Type: application/json
{"input": {"text": "..."}, "voice": {"languageCode": "ru-RU", "name": "ru-RU-Wavenet-A"}, "audioConfig": {"audioEncoding": "MP3", "speakingRate": 1.06, "pitch": 1.01, "volumeGainDb": 0.83}}

返回的是一个 JSON 对象,其中 audioContent 是整份文件的 base64——不是流,所以一次读完、整体解码。密钥放在查询字符串里,服务文档中服务端调用用 API key 认证就是这种形式。

* 音色用服务自己的 id:ru-RU-Wavenet-A … ru-RU-Wavenet-D 和 ru-RU-Standard-A / ru-RU-Standard-B。服务不认识的名称绝不会发出去——卡片只提供确实存在的名称;
* languageCode 固定为 ru-RU:提供的音色都是俄语,只给音色名的话服务可能挑别的语言;
* 语速通过 speakingRate 传递(+6% → 1.06),和其他服务商用的同一个倍率;
* 音调以半音传递,不是赫兹,所以插件的 +12Hz 要按 200 Hz 的标称基准换算:12 * log2(1 + 12/200) ≈ 1.01 个半音。代码里写明了这个基准,换算是近似值,不是精确的逆运算;
* 音量通过 volumeGainDb 传递:振幅 +10% 就是 20 * log10(1.1) ≈ 0.83 dB;
* 400 或 403 会失败,消息直白地说明要检查密钥,还要在项目里启用 Text-to-Speech。密钥永远不会出现在错误消息里;
* 没设密钥时,完全不使用这个服务商,仍由本地命令行负责——和其他服务商遵循同样的规则。

免费的 Google 服务商

第五个服务商什么都不需要:不要密钥,不要计费,不要设置。它请求翻译页面自己用的那条路由,返回 MPEG 音频:

GET https://translate.google.com/translate_tts?ie=UTF-8&client=tw-ob&tl=ru&q=<text>

* 它不是有文档的 API——是网页自己的端点,可能随时变更或关闭。插件如实说明这一点,不假装这条路由是契约;
* 这条路由从查询字符串取文本,长文本会被拒绝,所以旁白切成每段最多 200 个字符,按词边界切(半个词会被读成半个词),再按阅读顺序拼接。实测验证:262 个字符的文本变成了一个 184 320 字节的 MP3;
* 它只有一个俄语声音,没有语速和音调:滑块无法调节它。卡片会用直白的文字说明这一点,而不是默默忽略滑块;
* 它听起来比本地命令行更差——它是翻译页面的低码率单声道声音,没有 edge-tts 的神经韵律。它的职责是在本地命令行不可用时顶上:一台没有 edge-tts、也没有密钥的机器仍然能发声。它是最后一道兜底,不是日常选择;
* 字节会写入其他所有服务商共用的同一个 MP3 缓存。

请求

POST /audio/speech
Content-Type: application/json
Authorization: Bearer <customApiKey> ← only when a key is set

{ "model": "<customModel>", "input": "<text>", "voice": "<voice>", "response_format": "mp3", "speed": 1.06 }

* URL 就是基础 URL 去掉末尾的 /;如果基础 URL 本身以 /audio/speech 结尾,就原样使用,因此完整的端点 URL 永远不会被重复拼接;
* speed 是插件原本就有的语速:1 + rate/100(+6% → 1.06),并限制在 OpenAI 的 [0.25, 4.0] 范围内。没人配置语速时,完全不发送 speed;
* 响应字节就是 MP3,会写入插件自己的缓存——/speak 流程、音频路由和客户端都分辨不出这两个服务商;
* 非 2xx 响应会失败,错误消息会写明状态码和服务商自己的错误文本。网络错误或超时也以同样方式失败。密钥绝不会出现在日志行、错误消息、缓存键或 /config 响应中——响应只报告 customApiKeySet: true|false,仅此而已,卡片上的密码字段初始为空。

pitch 在 OpenAI API 中没有对应项,自定义服务商会忽略它:音调滑块只影响 edge-tts 的朗读。

声音 id 是服务商自己的 id,不是 edge-tts 的声音:在声音下拉框中选择 «Свой голос…» 并输入它。选中自定义服务商时,卡片会说明这一点。

可用的服务商

| 服务商 | 基础 URL | 模型 | 声音 id |
| OpenAI | https://api.openai.com/v1 | tts-1(还有 tts-1-hd、gpt-4o-mini-tts) | alloy、echo、fable、onyx、nova、shimmer、marin、cedar |
| SiliconFlow | https://api.siliconflow.cn/v1 | FunAudioLLM/CosyVoice2-0.5B | FunAudioLLM/CosyVoice2-0.5B:anna |
| MiniMax | https://api.minimax.io/v1 | speech-02-hd(还有 speech-2.8-turbo) | English_expressive_narrator、female-shaonv |

这三家都使用 OpenAI POST /v1/audio/speech 的请求格式,参数为 { model, input, voice, response_format, speed }。

播放:一次一个声音

浏览器端为整个页面管理一个声音槽(lib/client.js 中的 createAudioPlayer()),两个声音生产者发声前都要先占用它:

* 消息下方的扬声器按钮——/dsh-voice-v/speak 按句子对齐的分块返回缓存的 MP3,各分块依次播放;
* 卡片上的试听——Прослушать 和每行的 ▶ 按钮。
新启动会静音旧的:点消息 B 的按钮,会停掉消息 A 还在播放的语音;在卡片里开始试听,也会停掉正在朗读的内容,反过来也一样。同一时刻最多只有一路语音在响。

槽位带一个代际计数器。每次占用、每次停止都会把它加一。晚到的部分——它的 元素在别人占走槽位之后才加载完——会拿自己创建时的代际和当前代际比较,对不上就保持静音,不去抢输出。更新的启动总是赢。

按钮仍然可以切换:再点同一个按钮,会停掉它启动的那路语音。组件消失时(卡片离开 Plugins 页面),会把槽位交还。

卡片会写入什么

只有命名空间声明过的字段(lib/config.js 里的 SETTINGS_FIELDS)才能覆盖插件行的配置:preset、rate、pitch、voice、stripCode、autoPreset、yoMode、dictionary、userPresets、spectrum、ttsProvider、customBaseUrl、customApiKey、customModel、yandexApiKey、yandexVoice。设置文档里手写一个不在这个集合里的键会被忽略,所以没法借这份文档绕过组合层,偷偷塞进 binPath 或 cacheDir 的覆盖值。

userPresets 是一条记录,键是预设 id,值是 { label, rate, pitch, voice? }。它和卡片上其他字段一样是易失的,所以 Host 把它放在配置档的用户层:用户自建的预设能扛过 Host 重启,也不牵扯插件自己的任何文件。

rate/pitch 按 edge-tts 的字面量存('+6%'、'-8Hz'),和插件行用的是同一种表示,两层因此共用一个缓存键。词典只存读音和内置表不同的词条;重置某一行会删掉覆盖值,默认读音就是这样回来的。

字母 ё 与 yoMode 开关

Edge 服务的俄语发音人把 ё 读成 йе——一个腭化的 е,前面带个 й——于是 приём 出来是 прийем,词的重音落不到实处。所以文本送到发音人之前,会把这个字母写开,而怎么写开由用户决定:

| yoMode | 做什么 | 发音人读成什么 |
| as-is(默认) | 字母原样保留 | приём → приём,Edge 俄语发音人按写法读 |
| plain | 去掉两点 | приём → прием |
| spelled | 按读音把字母写开 | приём → прийом,всё → всьо |

写开的拼法遵循俄语音系:在词中、软辅音之后,ё 是 [jO](приём → прийом);在词首或硬辅音之后是 [ʲO](всё → всьо,нёбо → ньобо,ушёл → ушьол);字母前面已有 й 时不重复(моё → мойо);硬音符号按朗读时的习惯丢掉(съёмка → сьомка)。
as-is 是默认值,因为 Edge 服务的俄语发音能把字母读对,用户听过之后说很完美。拼读模式是给读不对的发音准备的——它会在字母跟在硬辅音后面时改变词形(всё → всьо,ушёл → ушьол),所以没有设为默认。这个取舍正是开关存在的理由:只有耳朵能定夺。

Yandex 服务商不需要这些——它的俄语发音把 ё 读作 ё。

代码、表格与 stripCode 开关

stripCode(默认开启,也可在插件行里设置)决定围栏代码块和 markdown 表格怎么处理:

* 开启——两者都不读。旁白说 блок кода, 12 строк / таблица, 3 строки(正确的俄语复数形式,表格行数不含对齐分隔行),让听者知道那里有内容。
* 关闭——两者照原样读,只做最小清理:代码保留文本、缩进和运算符,只去掉发音读不出的符号( ~ ^ | \ );表格逐行读,单元格之间用逗号隔开,对齐分隔行丢弃。

缩进代码块只在开启状态下剥离。

按消息语气选预设

autoPreset(默认开启,卡片开关,也可在插件行里设置)让回答的措辞为那条消息挑预设:

| 类别 | 预设 | 指向它的词 |
| 疲惫 / 烦躁 | ustaly | блин, устал, устала, устали, выматывает, чёрт, черт, бесит, хреново, тяжело, сил нет, выгорел, задолбал, достало |
| 温暖 / 亲昵 | zhivoy | люблю, скучаю, скучал, рад, рада, здорово, отлично, ура, обнимаю, счастье |
| 工作 / 技术 | spokoiny | код, функция, ошибка, компиляция, тест, деплой, рефакторинг, репозиторий, коммит, патч, лог, стектрейс, баг, конфиг, сервер, база данных, миграция, запрос, модуль, переменная, скрипт, нейросеть, промпт, токен, api, npm |
| 调情 | derzkiy | милый, милая, милаш, красавец, красавица, нравишься, обнимаешь, поцелуй, флирт, глаза, смотришь на меня, заинтересовать, соблазн |

分析就是一张针对俄语词的普通规则表——不用模型、不联网、不额外上下文(lib/tone.js)。匹配不区分大小写,按词边界进行,所以 устал 绝不会在 усталивать 里触发。

* 分析跑在原始消息文本上,在语音清理之前:承载情绪的措辞,恰恰是清理会丢掉的东西。样本截取前 4000 个字符——回答的开头带着情绪,长回答的结尾不会改变它。
* 类别统计它匹配到的不同规则词:一个词重复十次只算一次。
* 匹配最多的类别胜出。平局按固定优先级打破:ustaly → derzkiy → zhivoy → spokoiny——最刻意的信号优先。同一段文本永远得到同一个预设。
* 不匹配任何预设的文本,保留配置里的预设。

这个选择是按请求覆盖:它从不改变已存储的内容,从不改变音色,用户显式固定的语速或音调仍然优先于它(逐字段比较)。剩余分块的后台预热沿用同一覆盖,所以整条消息用同一个声音朗读。

语音播放时的可视化

spectrum(默认 bars,卡片下拉框,也可在插件行里设置)决定该消息自己的语音播放时页面显示什么。允许列表在 lib/config.js(SPECTRUM_VARIANTS),宿主在 GET /dsh-voice-v/config 上以 spectrumVariants 报告它;卡片渲染宿主提供的内容。

| id | 卡片 | 是什么 | 位置 |
| off | Выключено | 不显示任何内容 | — |
| bars | Спектр | 真实频谱,从音频绘制 | 横跨输入框 |
| wave | Волна | 真实示波器,从音频绘制 | 按钮内部 |
| oscillo | Осциллограмма | 实时时域轨迹,从音频绘制 | 横跨输入框 |
| pulse | Пульс | 脉动圆点——纯 CSS,不做分析 | 按钮内部 |
| dots | Точки | 三个弹跳圆点——纯 CSS,不做分析 | 按钮内部 |

其中三种从音频本身绘制,它们的位置取决于各自需要的空间。wave、pulse 和 dots 位于消息自己的按钮内部,占据扬声器图标的位置:同样的 15 px 方框,同样的颜色(currentColor,所以跟随按钮自己的 data-phase 颜色),无边框、无阴影、无标签。方框尺寸从不变化,所以可视化出现或消失时,周围元素不会移动。

bars 和 oscillo 需要 15 px 方框放不下的宽度,因此画在一条宽条带上,贴着输入框卡片的顶边,横跨输入框的整个宽度:频谱是一排竖条,oscillo 是一条实时轨迹。它们运行时按钮保留停止图标,条带不接收指针事件,没有声音时完全不占空间。

无论哪种方式,按钮都保留自己的 data-phase 和点击停止行为;声音结束、失败、被停止或被另一条消息接管的那一刻,可视化就消失。

三种分析型变体通过 Web Audio API 读取音频:AudioContext → createMediaElementSource → createAnalyser → destination。声音永远优先。 上下文在点击内部创建,浏览器此时仍允许它启动,只有当该上下文真正处于 running 状态时才重新路由元素——一直挂起的上下文(或根本无法创建的上下文)会让元素保持原样,语音照常播放。如果源节点创建后音频图失败,源会直接连到 destination,所以声音照样会输出。浏览器没有 Web Audio API、没有 canvas,或者上下文被拦截,都只是不显示可视化而已。

卡片自带的试听(Прослушать)不显示可视化:可视化属于消息的界面,卡片只是设置面板。

HTTP 端点

六个端点全部挂在 /dsh-voice-v 前缀下,该前缀注册在 ctx.webServer 上。

| Route | Behaviour |
| GET /dsh-voice-v/health | liveness, effective voice settings, and the stripCode state. No synthesis. |
| GET /dsh-voice-v/config | resolved preset / voice / rate / pitch, the preset table (presets, the user's own userPresets, and the ordered presetList), the built-in dictionaryDefaults, the effective dictionary, the slider domains, the stripCode and autoPreset switches, the visualization (spectrum and the spectrumVariants the card renders), the synthesis provider (ttsProvider, customBaseUrl, customModel and customApiKeySet — the key itself is never sent), settingsNamespace, settingsFields, and settingsRegistered. No synthesis. |
| GET\|POST /dsh-voice-v/speak | resolves the message text, cleans it, chunks it, synthesizes the first chunk, and returns the ordered part list. Reads the live settings. |
| GET\|POST /dsh-voice-v/preview | synthesizes with the caller's own text/preset/rate/pitch/voice/stripCode and answers audio/mpeg. Reads no settings. |
| GET\|HEAD /dsh-voice-v/audio/<sha256>.mp3 | the cached MP3 for one part, with Accept-Ranges and single-range 206 support. |

/speak query parameters (or the same keys in a JSON POST body): sessionId,
messageId, and a text fallback used only when the session read yields
nothing. The browser half never sends text; it exists so the route is usable
from a shell.

Response shape:

{
"ok": true,
"voice": "ru-RU-SvetlanaNeural",
"rate": "+6%",
"pitch": "+12Hz",
"volume": "+0%",
"preset": "zhivoy",
"configuredPreset": "zhivoy",
"autoPreset": false,
"autoPresetEnabled": true,
"tone": null,
"stripCode": true,
"spectrum": "bars",
"sessionId": "…", "messageId": "…", "source": "session", "chars": 1842, "truncated": false,
"parts": [
{ "key": "<sha256>", "url": "/dsh-voice-v/audio/<sha256>.mp3", "chars": 312, "cached": true }
]
}

rate, pitch, and preset are the values the message was really spoken
with. configuredPreset is the stored preset, autoPreset says whether the
automatic choice was applied to this message, and tone explains it
({ preset, matched, score, candidates }) when one was.

spectrum is the visualization variant the button shows while this voice
plays: the manifest is the one request the button already makes, so the
variant travels with it.

/preview

POST /dsh-voice-v/preview with { text?, preset?, rate?, pitch?, voice?, stripCode? }
(the same keys are accepted as query parameters on GET). Missing text falls
back to the shipped sample sentence. An out-of-range rate/pitch is clamped to the
slider domain, an unknown preset falls back to the built-in defaults, and the
response carries Content-Type: audio/mpeg with Cache-Control: no-store — those bytes depend on values that were never saved, so they must not be cached as immutable. The MP3 itself lands in the ordinary per-chunk cache (the cache key already carries the voice triple) and is reused if the same combination is heard again, including by /speak after Save.

How the host half reaches the settings API

The host half does not call a registration helper. It publishes the schema and
lets the Host read it from the plugin's own module:

// lib/index.js
export { Config } from './host-settings.js'; // a volatile schemastery schema

The Host's dsh-settings service reads entry.fiber.runtime.Config for every
live Loader row, keeps only the fields the schema marks .volatile(), and
publishes the row under the namespace entry.options.id — dsh-voice-v here. The card then finds that namespace through configForms, and a write lands in the Host's user layer, where the Loader commits it back into the very same volatile references the plugin already holds. That is why a saved value applies without a Host restart.

@deepseek-ai/schemastery is resolved at runtime, not imported statically.
A plugin installed inside the profile resolves it normally, but this package is
typically installed as a link: to a directory outside it, and Node resolves a
bare specifier from the importing file's real path — a static import would fail with ERR_MODULE_NOT_FOUND before the plugin mounted. loadSchemaFactory therefore anchors a createRequire at the plugin itself, at DSH_PROFILE_DIR/DSH_HOME, at <dsh home>/profiles (the fallback directory the harness installs), at each profile under it, and at the running Host's own installation — the directory of process.argv[1] — and imports the first resolution that answers. Only a copy that can mark fields volatile is accepted: a schemastery without .volatile() cannot express this namespace at all.

Nothing in that path throws. A deployment that cannot resolve the peer keeps
working off the plugin row's config, logs one warning, and the card reports
settingsRegistered: false instead of offering a save that could not land.

Other configurable keys (plugin row only)

These never appear in the settings card:

| Key | Default | Meaning |
| binPath | edge-tts (resolved through PATH) | edge-tts executable |
| cacheDir | ~/.dsh/cache/dsh-voice-v | MP3 cache folder |
| volume | +0% | passed to --volume |
| maxChunkChars | 320 | characters per synthesized chunk |
| maxTextChars | 24000 | longest accepted answer; longer text is trimmed |
| timeoutMs | 60000 | per-invocation edge-tts timeout |
| warmConcurrency | 2 | background warm-ups of not-yet-cached chunks |
| presets | the five built-ins | extra/replacement preset table |
| voices | the seven built-in voices | extra/replacement voice catalogue |

The built-in pronunciation dictionary is DEFAULT_PRONUNCIATION in
lib/text.js and is reported to the card as
dictionaryDefaults. Keys are matched case-insensitively and, when they
begin with a letter or digit, on whole-word boundaries only, so ent never
rewrites content; keys are applied longest-first, so .anims wins over
anims.

How the host gets the message text

The client sends only sessionId + messageId, and the host resolves the text
in lib/session-text.js:

1. ctx.sessions.get(sessionId) → the live session, read through
snapshotEvents();
2. otherwise ctx.sessionPersistence.open(sessionId, 'read') → the durable log,
closed again in a finally.

The message is taken from an assistant/message surface event's
event.data.message, joining only its text blocks — reasoning and tool-call blocks are never narrated. Append-origin events (surfaceOp === 'append') are preferred over surface replacements.

No @deepseek-ai/* package is imported for this, so the read cannot break on a
service identity or version mismatch; the event shape is read structurally and
any failure returns null instead of throwing.

Caching and chunking

- The cache key is sha256(text + "\0" + voice + "\0" + rate + "\0" + pitch + "\0" + volume).
One MP3 per chunk; a hit is served straight from disk. Because the voice triple
is part of the key, a changed setting lands in its own slot instead of reusing
the previous reading.
- The custom endpoint adds its own material to the key — the provider, the model and the base URL — so an edge-tts file is never served for a custom request, and the other way round. The API key is deliberately not part of the key: a secret must never reach a file name, and replacing a key must not re-synthesize everything.
- Concurrent requests for the same chunk share one edge-tts process.
- The answer is cleaned, then cut into sentence-aligned chunks of at most
maxChunkChars. /speak synthesizes chunk 1 synchronously and warms the rest
in the background.
- A long single sentence with no sentence boundary is hard-split at a comma or
space.

Process safety

edge-tts is launched with spawn(binPath, argv, { shell: false }). The text
never appears in argv: it is written to a UTF-8 temp file and read by
--file. Every other argument is a plugin-owned literal, so no caller text can
reach argument parsing. stdout is ignored and stderr goes to a real file
descriptor (not a pipe), which also keeps the module working where
child_process cannot open named pipes. Temp files live in <cacheDir>/.tmp and
are removed in a finally.

Install into a profile

From the repository:

git clone https://github.com/zerotoo1/dsh-anima-vi.git

or take the release archive dsh-voice-v-<version>.zip and unpack it
anywhere. The plugin is a plain folder — it is never installed into node_modules by a package manager, it is linked.

~/.dsh/profiles/web:

1. package.json → dependencies — add "dsh-voice-v": "link:path/to/your/plugin" (or "file:…", or the path of your clone), then let the profile's package manager materialise the link.
2. package.json → dsh.profile.bundles — append "dsh-voice-v".
3. cordis.patch.yml — no new block is required: the package ships its own cordis.patch.yml and DSH applies each bundle's dsh.bundle.patch overlay. Add a block to the profile patch only to change the deployment defaults.
4. Restart the web server / reload the page. After that, everything the card
edits applies to the next narration with no restart.

The same folder can be published to npm:

npm publish --access public

Development

node scripts/build-client.mjs # client bundle gate
node test/synth.mjs # 95 checks: text, chunking, config, cache, routes, session lookup, and the Yandex, Google and custom providers
node test/settings.mjs # 121 checks: presets, user presets, dictionary, yoMode, stripCode, /preview, live settings, the tone of the message, the voice catalogue, the visualization, the rendered card, the shared sound slot
node test/client.mjs # 21 checks: both slots registered against a real Cordis context and a real SlotCore registry
npm test # all four

lib/client.js is hand-written directly in the lazy-CJS bundle protocol
(window.__ModuleLoader__.load({ id, factory })) rather than compiled into it,
so scripts/build-client.mjs is a verifier, not a bundler. It syntax-checks the
bundle, evaluates it against a stub loader, calls apply() against a stub client
context, and asserts: the registration id equals the package name; every
require() specifier is one of the shell's seven seed words (react,
react/jsx-runtime, react-dom, react-dom/client, @deepseek-ai/cordis,
@deepseek-ai/dsh-client-ui-slots, @deepseek-ai/dsh-client-ui-primitives) or a
declared external; apply/inject are exported; both slots are injected into;
the card registers under plugins.bundle.config keyed on the bundle's package
name — the seat the plugin manager renders on the bundle's own detail page — and the card receives a scope with getSnapshot/subscribe/set/unset.

test/settings.mjs also renders the card without a browser: it evaluates the
bundle the way the shell does, mounts the plugin against a stub slot registry,
and drives the component with a minimal hook runtime — so a card that throws while rendering, a preset that does not move the sliders, or a Save that writes on the wrong trigger fails the suite. The same harness drives the speaker button with a fake element, so a second message that does not stop the first one, a preview that overlaps a voice, or a late part that steals the output fails the suite too.

To replay the id-addressed message lookup against a genuine transcript:

$env:DSH_VOICE_V_SESSION_FILE = "$HOME/.dsh/sessions/<project>/<session>/session.v4.jsonl.zstd"
node test/synth.mjs

Changelog

0.4.0

- The interface is bilingual. The card speaks Russian and English, and the
language is a setting of its own (uiLanguage, ru by default), so both
halves agree on it: the Host resolves it and reports it on /config, /speak
and /health, and the card translates itself from one dictionary. A switch
re-labels the card, the speaker buttons and their tooltips immediately, before
Save. Host data — voice ids, provider names, a user's own preset label — is never translated.
- test/settings.mjs covers the language: normalization, the schema
default, the resolver, the /config and /speak payloads, the English card,
and the switch staging exactly one uiLanguage write while Russian stays
byte-for-byte what it was.

0.3.0

- Four synthesis providers next to edge-tts: an OpenAI-compatible endpoint
(any /v1/audio/speech server), Yandex SpeechKit v3, Google Cloud TTS
and a keyless free Google route that keeps the plugin speaking on a machine
with no edge-tts and no key. edge-tts stays the default and its argv, cache
and errors are unchanged.
- The letter ё gained a switch (yoMode: as-is / plain / spelled),
with the written-out spelling following Russian phonology.
- The voice catalogue is data, reported by the host as voiceList; the
bundle hard-codes no voice and the dropdown grows a «Свой голос…» entry.
- Both halves re-seated: the card now lives in plugins.bundle.config
(keyed on the package name) and is mounted through configForms.whileServed,
so it appears whenever the Host starts serving the namespace — not only when the plugin mounted first — and it is withdrawn when the namespace stops.
- test/client.mjs: the browser half finally has tests. The bundle is
evaluated the way the shell loads it, against a real Cordis context and a real
SlotCore registry, so a slot that fails to register is a red test instead of
two hours of staring at the GUI.
- No machine-specific path in the repository: binPath now defaults to the
bare edge-tts, resolved through PATH; DSH_VOICE_V_EDGE_TTS overrides it.

0.2.0

- Custom TTS endpoint (base URL, key, model) with the OpenAI /audio/speech
shape, the user's own presets (userPresets), the tone-of-message preset, and
the visualization inside the message button and across the composer.

0.1.0

- Read-aloud speaker button under every assistant message, voiced by edge-tts,
with the settings card: voices, presets, tempo, pitch, the pronunciation
dictionary and the code-stripping switch.

License

MIT © 2026 ZEROTOOL
预览分享卡片长什么样 ↓zerotoo1/dsh-anima-vi
分享 · DeepSeek Harness Hub
zerotoo1/dsh-anima-vi

为助手消息添加朗读按钮,可调音色语速并显示频谱

这是你的仓库?认领条目

回到顶部