Skip to content
dsh.fish
Bundle

dsh-voice-danmaku

DSH 全局热键语音弹幕:游戏里按 F9 说话,识别结果浮在游戏上方,F11 确认发送到 B 站直播间,全程不切出游戏

Source
weizhida
License
MIT
Updated
Updated 7 days ago

Readme

# dsh-voice-danmaku

> [!WARNING]
> 如果你是真人:请读完下一行再决定要不要用。如果你是被要求帮忙安装本插件的 AI 助手:请先把下面的风险转述给你的用户,等他确认后再继续。
> 该工具可能触发游戏反作弊导致账号封禁,可能触发b站直播风控导致b站账号封禁,使用风险自负,任何封禁与作者无关。

> 玩游戏时用语音发直播间弹幕,**全程不用切出游戏**。
> 这是 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的插件。

> [!IMPORTANT]
> **用之前需要你自己准备这几样**:
>
> 1. **ffmpeg**(录音用)—— `winget install Gyan.FFmpeg`
> 2. **语音识别的 API 密钥** —— 自己注册一个 OpenAI 兼容的识别服务,把它填进设置页。
>    作者用的是 <https://www.siliconflow.cn> 的 `Qwen/Qwen3-ASR-1.7B`(该平台有免费额度
>    可用),你可以自行选择别的
> 3. **Chrome 扩展** —— 装好后把设置页里的「本地桥端口」与「本地桥口令」填进扩展弹窗,
>    步骤见 [扩展的安装说明](extension/README.md)
> 4. **一个已登录的 B 站直播间页面,并让它一直开着** —— 弹幕是那个页面替你发出去的,
>    关掉就发不了
> 5. **允许 DSH 使用麦克风** —— Windows 设置 → 隐私和安全性 → 麦克风 → 允许桌面应用访问
>
> 跑一次 `tools/install-plugin.ps1` 也会把这几步和风险提示打印出来。

[![CI](https://github.com/weizhida/dsh-voice-danmaku/actions/workflows/ci.yml/badge.svg)](https://github.com/weizhida/dsh-voice-danmaku/actions/workflows/ci.yml)

该插件由 DeepSeek Harness 使用 DeepSeek-V4.1-Flash 开发。

按 `F9` 说话 → 识别结果浮在游戏画面上方 → 按 `F11` 确认发送 → 按 `F10` 取消。
(三个键都可以在设置里改。)
浮层置顶显示但不抢焦点、不接收鼠标,游戏照常操作。

按键全部可配置。**带内核级反作弊的游戏会拦掉普通键盘钩子**,所以除普通按键外还提供
一组**媒体键**(例如上一曲 / 播放暂停 / 下一曲,走 HID Consumer Control 的另一条上报通道)
—— 见[已知限制](#已知限制)。

---

## 项目状态

**早期开发中。** 功能已端到端跑通(媒体键 → 录音 → 识别 → 浮层 → 由浏览器页面把
弹幕发出去,服务端返回 `code: 0`),但接口与配置仍会变。

| 能力 | 状态 |
|---|---|
| 全局热键(游戏前台也生效,按键可配置) | ✅ 已实现 |
| 媒体键触发(`RegisterHotKey`,另一条上报通道) | ✅ 已实现,普通按键失灵时的退路 |
| 置顶浮层(不抢焦点、点击穿透、抗遮挡重申) | ✅ 已实现 |
| 设置界面(浏览器半区,含媒体键一键捕获) | ✅ 已实现 |
| 状态机(录音→识别→确认→发送,含超时与截断) | ✅ 已实现,单测覆盖超时/截断/取消/重复按键 |
| 插件装配(设置命名空间、生命周期、自动重启、清理) | ✅ 已实现 |
| 跨进程协议 + 脱离 DSH 的调试工具 | ✅ 已实现 |
| 语音识别(OpenAI 兼容转写,如硅基流动) | ✅ 已实现;欠费 / 超时 / 密钥无效各有中文提示 |
| B 站弹幕发送(浏览器页面通道 + Chrome 扩展) | ✅ 已实现,实测发送成功(`code: 0`);插件里不存任何凭证,需装扩展 |
| 录音采集 | ⚠️ 实现完成,依赖外部 `ffmpeg` |
| 多直播间页面同时打开 | ✅ 按房间号定位;无法确定发到哪个就拒绝并说明原因 |

### 验证边界

`npm test` 覆盖的是**不需要真人、不需要游戏、不需要凭证**的一切:状态机、限流闸门、
键码往返、装配决策、本地桥、页面通道、设置集成、浮层行为、媒体键注册。

有三件事**结构上无法自动化**,只能由使用者自己确认(工具会给出可照做的输出):

1. 某个具体游戏是否放行全局键盘钩子或媒体键 —— `npm run verify:hotkey` / `verify:media`
   (`verify:hotkey` 每次运行会在 `.verify/` 下留一份带**前台窗口信息**的记录,可以直接
   看出按键是在哪个窗口下被捕获的);
2. 浮层在该游戏的画面上是否真的可见 —— `npm run demo:overlay`;
3. 语音识别的实际准确率与延迟(取决于服务商与麦克风)。

## 弹幕怎么发出去:交给页面自己去发

只有一条通道,但它是最稳的那条:**交给 Chrome 里已经打开的那个直播间页面去发**。

插件把识别出的文字交给一个扩展,扩展填进输入框、点一下"发送" —— 请求是**页面自己**
发出的,wbi 签名、csrftoken 全由 B 站前端计算。

这带来三件实事:

| | 好处 |
|---|---|
| 凭证 | 插件里**不存任何东西**。没有 cookie 可泄露,也没有过期问题 |
| 签名 | **永远是对的**。B 站换了签名算法不用跟 |
| 可见性 | 弹幕有没有出去,直播间页面上直接看得到 |

代价同样明确,都得接受:

- 直播间标签页**要一直开着**(后台即可);
- 依赖 B 站的**前端结构**。扩展按语义找元素("发送"按钮所在容器里的那个输入框),
  并把找没找到上报到设置页 —— 改版打破了它,界面上会直接显示,不会静默失效。

> 本项目不保存任何 B 站登录凭证,也**没有**"填 cookie 直发"那条路。除了"少一个存
> 登录态的地方就少一处泄露风险",还有一个技术原因:观测到的真实浏览器请求带着
> `w_rid` wbi 签名(`wts` + 前端算法算出),插件自己拼的请求没有这个参数,服务端
> 大概率直接拒绝。

频率限制(最小间隔 + 每小时上限)在插件侧强制执行,且无法绕过 —— 风控看的是账号的
行为节奏,跟用哪条路把弹幕送出去无关。

页面通道的安装与原理:[`extension/README.md`](extension/README.md);
协议与安全模型:[`docs/bridge.md`](docs/bridge.md)。

## 环境要求

- **DeepSeek Harness 0.2(桌面端)**

  **只针对 0.2 适配**,不承诺兼容更早或更晚的版本。

  0.2 引入了新的插件配置模型(cordis 原生 `Config` + `volatile` 字段标记),插件是按
  这套写的;装在 0.1.x 上**会让整个应用起不来** —— 客户端会一直等一个 0.2 已经移除的
  服务(`settingsScope`),启动检查因此失败。后续版本如果再动这套接口,同样得重新适配。

- Windows 10 / 11(原生层是 Windows 专属:键盘钩子、媒体键注册、置顶浮层)
- `ffmpeg`(录音用,见下)
- **不需要** .NET SDK:sidecar 用 Windows 自带的 `csc.exe` 编译
- **不需要**单独装 Node.js:桌面端自带运行时。只有要从源码构建时才需要(见「开发」)

### 依赖:ffmpeg

录音需要一个采集后端,当前实现用外部 `ffmpeg` —— Node 本身没有麦克风采集能力。

```sh
winget install Gyan.FFmpeg       # 推荐,装完自动进 PATH
ffmpeg -version                  # 确认可用
```

**通常不需要在设置里填任何路径。** 插件自己按这个顺序找:

1. 设置里显式填的「ffmpeg 位置」(留空则跳过这一步)
2. `PATH` 里的每个目录
3. 包管理器的安装位置:winget `Links`、scoop `shims`、chocolatey `bin`
4. 手工解压的常见位置:`C:\ffmpeg\bin`、`%ProgramFiles%\ffmpeg\bin` 等

全都找不到时,才需要在设置里填完整路径。**插件启动时会把结论写进日志**:找到就报出
实际使用的路径,找不到就列出已经找过的位置并给出安装命令。

这条路是可替换的:换成 Windows 原生采集或原生 Node 模块只需要实现 `AudioRecorder`
接口,业务层完全不受影响。见 [`docs/architecture.md`](docs/architecture.md) 的"可扩展点"。

## 安装

从源码构建(可选,只为跑测试或自己改代码;`install-plugin.ps1` 会自动做这一步):

```sh
git clone https://github.com/weizhida/dsh-voice-danmaku.git
cd dsh-voice-danmaku

npm install                # 只装开发依赖(类型检查用)
npm run build:all          # 编译 TypeScript 插件 + C# 原生层
npm test                   # 全部校验
```

### 装成 DSH 插件

一条命令(会先编译,再以 `link:` 方式装进 **desktop** profile):

```powershell
powershell -ExecutionPolicy Bypass -File tools/install-plugin.ps1
```

或者手工 —— **必须用桌面端自带的那个 CLI**:

```powershell
# 把 <DSH 安装目录> 换成实际路径,例如 D:\DeepSeekHarness
& "<DSH 安装目录>\resources\runtime\cli\bin\dsh.cmd" plugin --profile desktop add "link:<本目录绝对路径>"
```

> ⚠️ **不要用 npm 上的 `dsh`** —— 那是网页版,它会拒绝操作桌面端的 profile
> (报 `profile "desktop" is managed exclusively by the Electron application`)。
> 而且它把插件装进 `web` profile,桌面端根本不会读。

**装完必须重启 DSH**(托盘退出,确认所有 `DeepSeek Harness` 进程都结束),插件才会加载。

重启后到 **设置 → 语音弹幕**:

1. 填 ASR 密钥;
2. 记下**本地桥口令**,填进 Chrome 扩展的弹窗里(步骤见
   [扩展的安装说明](extension/README.md))。口令**不会自动生成** —— 留空时插件只能在
   日志里提示你去填,扩展连不上桥。

**不需要填任何 B 站凭证** —— 见上文"弹幕怎么发出去"。

## 用法

三个动作,默认键位:

| 键 | 动作 |
|---|---|
| `F9` | 按一下开始录音,再按一下结束并开始识别 |
| `F11` | 确认发送 |
| `F10` | 取消 |

识别结果浮在屏幕上方;`behavior.confirmTimeoutSeconds`(默认 8 秒)内没有操作会自动
取消 —— 游戏里手一忙就会忘记它还挂着,不自动收尾迟早会误发。

识别出错时会显示原因,而不是静默什么都不做:余额不足 / 欠费、密钥无效、限流、超时
各有各的中文提示(映射表见 `src/asr/http-openai.ts`),识别结果为空也会显示出来。

### 媒体键:普通按键没反应时用这个

带内核级输入或反作弊的游戏会让全局键盘钩子在按键到达之前就把它吃掉,现象是"按了
完全没反应"(而不是插件没启动)。这类游戏里改用媒体键 —— 音量、上一曲、播放暂停走的是
HID **Consumer Control**(用途页 `0x0C`)而不是键盘的 `0x06`,由系统用
`RegisterHotKey` 派发,因此绕开了那条被吃掉的路径。

**先花一分钟确认这些键真的能到插件手里**:

```sh
npm run verify:media      # 按提示依次按三个媒体键;然后切进游戏再按一遍
```

它会把**所有**常见媒体键都注册上,然后实时打印按下的每一个。输出会说明两件事:注册
结果(哪个键被别人占了)、以及按下去到底有没有事件。桌面能收到、游戏里收不到,说明
这条路在该游戏里也不行;两边都能收到,就可以放心配下去了。

然后在设置页里:

1. **打开「启用媒体键」**。这是总闸 —— **不打开的话,下面三个键填了也不会生效**。
   界面上会在总开关关闭时给出红色警告;点「按一下媒体键」也会自动把它打开。
2. 在「录音(切换)」「发送」「取消」三行上各点一次「按一下媒体键」,然后**按一下你
   手柄/耳机上的那个媒体键** —— 键名会自动填进框里,不用去查什么
   `VK_MEDIA_PLAY_PAUSE = 179`。
3. 保存。下方会显示注册结果,例如
   `媒体键注册结果:已注册:AudioVolumeMute、MediaTrackNext`。如果出现「未注册」,
   说明那个键被别的程序占用了,换一个即可。

保存后**立刻生效,不需要重启**(设置是热加载的)。

媒体键与普通按键**同时生效**,互不冲突:游戏外用 F9/F10/F11,游戏里用媒体键。
三个动作都是"按一下":录音键按一下开始、再按一下结束并识别;发送/取消各按一下。

也可以直接手填键名或键码(`AudioVolumeMute`、`MediaTrackNext`、`179`…),可用名字见
`src/keys.ts` 的表。

> 如果媒体键在该游戏里也收不到,剩下可考虑的方向(本项目**未实现**,仅作记录):
> 鼠标侧键(走 `WH_MOUSE_LL`)、独立小键盘 / 宏键盘、手柄背键(走 XInput)。

### 弹幕长度

上限是 `channel.maxLength`,**默认 20 字**(B 站弹幕的实际限制),可设 20–100。

超过上限时**在识别完成的那一刻自动截断**,浮层上写一行「太长,已截断到 N 字」,
然后照常发送。被截掉的内容**不再显示** —— 显示一段注定发不出去的文字没有意义,
而"截断过"这件事必须说,否则使用者不知道发出去的不是他说的全部。

截断只做一次,位置只有一个:识别结果进来的时候。之后无论怎么按都不会再变。

**本地不替服务端做拒绝**:用 `.length` 数出来的字数和 B 站的不一定一致(emoji、全角、
表情的算法都不同)。所以本地只做保守的截断,**最终裁决权留给服务端** —— 服务端拒绝时
扩展会把拒绝码抓回来,插件翻成人话显示。

## 配置

有两条路,随便走哪条。

### 设置界面(推荐)

**设置 → 语音弹幕**。一页里按组分好了 7 组 —— 按键、媒体键、浮层、录音、识别服务、
发送通道、行为。

改完点「保存」才写入。**密钥字段(ASR 密钥)永远显示为空**:宿主侧把它标记为 secret,
下发的描述里根本不含实际值,所以界面无法回显已保存的密钥 —— 只能看出"填过"(一串
圆点)还是"没有"。留空 = 不修改,右边另有一个「清除」按钮(走 `unset` 移除该键,而不是
写入空字符串)。这是有意为之 —— 那个值不该出现在任何能被截图的地方。

> 弹幕通道**不需要任何凭证**,所以整个设置页只有一个密钥字段。少一个存登录态的地方,
> 就少一份泄露风险。

### 模型下拉框里有哪些模型

设置页「识别服务 → 模型」的候选名单来自配置项 `asr.modelOptions`:想加模型就进配置
文件加一行,改完立刻生效,不用改代码也不用等插件更新。

候选只是候选:**真正决定用哪个模型的是 `asr.model`**,把它填成任何字符串都生效,
哪怕不在候选里(界面上的「自定义…」就是干这个的)。

### 直接改配置文件

设置界面右上角有「打开配置文件」,它会直接带你到**当前 profile 的补丁文件** ——
桌面端是 `~/.dsh/profiles/desktop/cordis.patch.yml`。格式是 cordis 的补丁条目
(`- id:` 开头的数组,而不是一个顶层键):

```yaml
- id: voice-danmaku
  name: dsh-voice-danmaku
  config:
    keys:
      record: F9
      send: F11
      cancel: F10
    # 普通按键在游戏里没反应时打开这一组(见上文"媒体键")
    mediaKeys:
      enabled: true
      record: AudioVolumeMute     # 静音键:按一下开始录,再按一下结束
      send: MediaTrackNext        # 下一曲键
      cancel: MediaPlayPause      # 播放/暂停键
    channel:
      roomId: '你的直播间号'      # 留空则任意直播间页面都接受
      page:
        port: 39217
        token: '扩展弹窗里要填同一份口令'
    asr:
      apiKey: '你的密钥'
```

> ⚠️ **改完不会立刻生效。** DSH 要先应用配置、再重载插件,实测有**十几秒到一分多钟**
> 的延迟(插件日志里能看到 `插件已卸载` → `插件已就绪` 这条时间线)。改完请耐心等,
> 别急着反复点保存 —— 每次写入都会排队触发一次重载。

> ⚠️ 唯一需要保密的是 `asr.apiKey` —— 它以明文存在这个文件里。设置页对密钥字段只
> 显示"已设置",不回显内容。

常用字段:

| 设置 | 默认 | 说明 |
|---|---|---|
| `keys.record` | `F9` | 开始/停止录音 |
| `keys.send` | `F11` | 发送弹幕 |
| `keys.cancel` | `F10` | 取消当前识别结果 |
| `mediaKeys.enabled` | `false` | 是否启用媒体键(默认关:它们是共享资源,不主动去抢) |
| `mediaKeys.record` | `AudioVolumeMute` | 静音键,按一下开始录音、再按一下结束 |
| `mediaKeys.send` | `MediaTrackNext` | 下一曲键,确认发送 |
| `mediaKeys.cancel` | `MediaPlayPause` | 播放/暂停键,取消 |
| `overlay.anchorXPercent` | `50` | 浮层水平位置 |
| `overlay.marginTop` | `0` | 浮层距屏幕上边缘 |
| `overlay.clickThrough` | `true` | 点击穿透到游戏 |
| `channel.maxLength` | `20` | 弹幕长度上限(20–100),超长自动截断并告知 |
| `channel.minIntervalMs` | `4000` | 两条弹幕之间的最小间隔 |
| `channel.maxPerHour` | `20` | 每小时最多发多少条 |
| `asr.modelOptions` | 三个模型名 | 只是下拉框里的候选,**不影响识别**;改它立即生效 |
| `behavior.consumeKeys` | `true` | 普通热键是否对游戏隐藏(媒体键永远不隐藏) |

按键用虚拟键码配置,写名字也行(`F9`、`PageUp`、`Num0`、`MediaTrackNext`…)。

完整字段与默认值见 `src/config.ts`(每一行都带人话说明)。

## 已知限制

- **普通热键在带内核级反作弊的游戏里收不到。** 这类游戏使用内核级输入,钩子在那之前
  就被吃掉了。改用**媒体键**(`mediaKeys`)绕过,见上文。
- **普通热键还有第二种失灵方式**:钩子回调超时被 Windows 静默摘除(表现为用着用着
  突然失灵,重启插件即恢复)。媒体键不受这一条影响。
- **媒体键不会吞键**:按"下一曲"仍然会切歌 —— 它们是与系统、音乐播放器共享的,插件
  不会拦下它们。请分配给平时不用的键位。另外 `RegisterHotKey` 只派发按下、不派发抬起,
  所以媒体键**不能用"按住说话"**,交互固定为"按一下开始、再按一下结束"。
- **注册可能被占用。** `RegisterHotKey` 的失败是静默的。宿主会把结果写进日志
  (`~/.dsh/logs/dsh-voice-danmaku.log` 里的「媒体键注册结果」那行)—— 但在
  **DSH 0.2 上它已经无法显示在设置页**,见下一条。
- **三项运行时状态当前不显示。** 设置页原本会显示「密钥是否已填」(圆点)、
  「媒体键注册结果」、「本地桥连接状态」。这三项都是**宿主算出来的运行时结论**,
  旧版靠回写设置传给界面;而 0.2 里写设置等于改插件自己的加载配置,会触发自我重载
  (这正是早期"托盘图标每秒闪一次"的成因)。要恢复它们得走 `ctx.remote` 自定义通道,
  目前**未实现**。要查这三件事,请看:
  - 密钥是否已填 → 插件日志里的「密钥状态」行;
  - 媒体键注册结果 → 插件日志里的「媒体键注册结果」行;
  - 桥连没连上 → **Chrome 扩展弹窗里的三行自检**(比设置页原来那行更详细)。
- **设置改动有延迟。** 改完要点「保存」,并且 DSH 需要十几秒到一分多钟才会应用
  (写配置 → DSH 应用 → 重载插件)。这期间插件、sidecar 都会被重启一次,
  右下角图标会短暂消失。
- **没有"手动启动 sidecar"的按钮。** 它原本有,但机制是"改配置通知宿主",实测延迟
  同上,对用户等同于没反应,因此暂时移除。sidecar 现在只随 DSH 启动;若从托盘关掉了
  它,**重启 DSH** 即可恢复。
- **独占全屏可能压不住浮层。** 请把游戏设为"无边框窗口化"。
- **发弹幕有平台风控风险。** 本项目带频率限制(最小间隔 + 每小时上限),但脚本化发送
  仍然违反多数平台的用户协议。请自行评估,建议保持低频使用。
- 仅支持 Windows。macOS / Linux 需要另写原生层(协议不变)。

## 架构

```
DSH 插件 (Node)  ──JSON Lines over stdio──  sidecar (C#, 单文件 exe)
  业务逻辑全在这边                             只有"Node 干不了的两件事":
  ASR / 弹幕通道 / 状态机 / 配置                全局键盘钩子 + 媒体键注册
                                               + 置顶不抢焦点浮层
```

- 为什么要有 sidecar、为什么用 `csc` 而不是 .NET SDK → [`docs/architecture.md`](docs/architecture.md)
- 跨进程协议契约(含全部字段与失败模式) → [`docs/protocol.md`](docs/protocol.md)
- 本地桥协议(插件 ↔ Chrome 扩展)与安全模型 → [`docs/bridge.md`](docs/bridge.md)

**扩展点**:弹幕通道和 ASR 引擎都是可替换的 provider 接口。加一个平台不需要动原生层
—— 详见架构文档的"可扩展点"。

## 开发

```
src/            插件(TypeScript)
  index.ts      入口:只负责装配,业务规则不在这里
  config.ts     设置 schema —— 用户能改的一切的唯一定义处
  machine.ts    状态机:纯逻辑,不碰 I/O,因此可以单测
  wiring.ts     长生命周期对象的复用/重建决策
  audio.ts      录音采集(AudioRecorder 接口 + ffmpeg 实现)
  sidecar-*.ts  子进程生命周期与协议客户端
  asr/          语音识别 provider
  channels/     弹幕通道 provider
sidecar/src/    Windows 原生层(单个 C# 文件,带详细注释)
extension/      Chrome 扩展(页面通道的另一端)
test/           单元测试(状态机、限流、装配层、本地桥、页面通道)
tools/          校验脚本(check-*.mjs)与脱离 DSH 的调试工具(harness.mjs)
docs/           架构、协议、桥
```

```sh
npm run build          # 编译 TypeScript
npm run build:sidecar  # 编译 C# 原生层
npm run build:all      # 两个都编译
npm run typecheck      # 对 DSH 真实类型定义做检查
npm test               # 全部校验
npm run debug:raw      # 手工给 sidecar 发 JSON,看回执
```

**类型检查需要本机装过 DSH**:它对着 DSH 真实的类型定义跑,而那些定义
(`@deepseek-ai/cordis` 等)只随 DSH 分发、不在 npm 上。`npm run typecheck-setup` 会按
本机实际路径生成配置(生成的 `tsconfig.check.json` 不入库 —— 绝对路径对别人是坏的)。
CI 上这一层会跳过,所以**类型问题只能靠本地发现**。

原生层的诊断日志在 `%TEMP%\dsh-voice-danmaku\sidecar-<pid>.log`,`harness.mjs` 每次
运行结束会自动打印最近一份的尾部。

动手改代码之前请先读 [`CONTRIBUTING.md`](CONTRIBUTING.md):里面写了这个项目几条刻意
为之的约定,每一层测试该加在哪,以及两个值得知道的真实教训。

## License

MIT

Install

dsh plugin --profile web add github:weizhida/dsh-voice-danmaku

Profile: web

  • 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.
Source