dsh-speak 1.4.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +166 -72
- package/README.zh-CN.md +160 -71
- package/adapters/dsh/speech-hook.js +498 -102
- package/client/client.js +304 -0
- package/docs/DESIGN.md +122 -30
- package/docs/DESIGN.zh-CN.md +108 -25
- package/engine/speak.ps1 +88 -14
- package/engine/speak.sh +66 -52
- package/package.json +36 -3
package/docs/DESIGN.zh-CN.md
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# DESIGN.zh-CN.md — dsh-speak:为 AI 编程 harness 提供语音播报
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
是 README 的参考依据。
|
|
3
|
+
状态:**维护中** — 本文档描述当前实现与仓库结构,是 README 的参考依据。
|
|
5
4
|
|
|
6
5
|
(英文版:docs/DESIGN.md)
|
|
7
6
|
|
|
@@ -32,11 +31,12 @@ Agent 工具会跑长任务(构建、测试、迁移、批量修改),而
|
|
|
32
31
|
|
|
33
32
|
非目标(当前阶段):
|
|
34
33
|
|
|
35
|
-
- Windows
|
|
36
|
-
|
|
34
|
+
- 不支持 Linux/无头 TTS(Windows 用 `speak.ps1` + SAPI5;macOS 用 `speak.sh`
|
|
35
|
+
+ 系统 `say`,macOS 引擎自 1.2.0 起随 npm 包分发)。
|
|
37
36
|
- 在仓库内打包 NaturalVoiceSAPIAdapter(仅 Windows 10 需要)或语音数据——
|
|
38
37
|
它们是前置依赖,不打进仓库。
|
|
39
|
-
-
|
|
38
|
+
- 按音色输出音频文件、非中文音色管理(语音**播放队列**已在 1.7.0 实现——
|
|
39
|
+
见 §3.2 的 host FIFO 队列)。
|
|
40
40
|
|
|
41
41
|
## 3. 架构
|
|
42
42
|
|
|
@@ -106,13 +106,29 @@ Agent 工具会跑长任务(构建、测试、迁移、批量修改),而
|
|
|
106
106
|
- 监听 `session/event`;
|
|
107
107
|
- 过滤 `assistant/message` 且 `surfaceOp == 'append'` 的事件;
|
|
108
108
|
- 只提取 `text` 内容块(reasoning / tool_use 块跳过);
|
|
109
|
-
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
109
|
+
- 默认模式:缓冲文本并启动节流定时器(默认 1500 ms)以合并同一回复的多步消息,
|
|
110
|
+
`tool/call` 事件会**取消**待播报(该轮 assistant 文本是过程旁白),但
|
|
111
|
+
**`turn/end` 会在回合结束时兜底补播最终回复**(带工具调用的回复也能听到);
|
|
112
|
+
- **host 语音队列**(1.7.0,源自 PR #2):所有播报(最终回复、审批、提问、
|
|
113
|
+
可选事件、手动重播)统一进入 FIFO 队列,**同一时间只运行一个语音进程**,
|
|
114
|
+
读完自动播下一条;`/dsh-speak/control` POST 路由(play/stop/status)和
|
|
115
|
+
`/dsh-speak/ws` WebSocket 广播权威播放状态(正在读哪条、队列长度)。
|
|
116
|
+
- **最终回复重播**(1.7.0):回合尾部(最终回复)操作栏的 🔊 按钮调 control
|
|
117
|
+
路由重播**该条最终回复**;语音执行完全由 host 拥有(浏览器关掉也继续)。
|
|
118
|
+
- **`queueAllMessages` 开关**(1.7.0,默认关):关 = 默认节流最终回复 + 可选
|
|
119
|
+
事件;开 = 每条 assistant 消息立即入队朗读(中间消息也读)。
|
|
120
|
+
- **可选事件播报**(1.6.0,默认全关):`turn/end`、`command/done`、
|
|
121
|
+
`goal/change`、`tool/result`(出错时)、`todo/write` 各自独立开关(见 §5)。
|
|
122
|
+
- **settings namespace 注册**(1.6.0):apply 后在一个 timer tick 里调用
|
|
123
|
+
`installSettingsSection(ctx, 'dsh-speak', schema, patchConfig, hooks)`,配置
|
|
124
|
+
解析为 schema 默认 → patch `config` → UI 用户设置三层。`onChange` 时通过
|
|
125
|
+
`settingsSource()` 重新解析 cfg(注意:`installSettingsSection` 只在
|
|
126
|
+
attach/detach 时调 `setSource`,变更时需自己在 `onChange` 里重读 source)。
|
|
127
|
+
若宿主没有 settings 服务(dsh < 0.1.0-rc.7 或未挂载 provider),注册静默
|
|
128
|
+
跳过,插件完全按 patch `config` 工作——向后兼容。
|
|
129
|
+
|
|
130
|
+
注册片段(`install.ps1` 也会自动完成;npm 安装用裸包名 `'dsh-speak'` 即可,
|
|
131
|
+
这是文件安装方式用的路径):
|
|
116
132
|
|
|
117
133
|
```yaml
|
|
118
134
|
# ~/.dsh/profiles/web/cordis.patch.yml
|
|
@@ -125,6 +141,29 @@ Agent 工具会跑长任务(构建、测试、迁移、批量修改),而
|
|
|
125
141
|
> Node 的 ESM 加载器不接受 Windows 绝对路径作为插件名——必须用
|
|
126
142
|
> `file:///C:/...` URL 形式。
|
|
127
143
|
|
|
144
|
+
### 3.4 DSH 浏览器端 — `client/client.js`
|
|
145
|
+
|
|
146
|
+
一个 DSH client bundle(`window.__ModuleLoader__.load({ id: 'dsh-speak',
|
|
147
|
+
factory })`),注册两条 UI:
|
|
148
|
+
|
|
149
|
+
- **回合尾部的 Speak 按钮**(1.7.0,源自 PR #2):注册进
|
|
150
|
+
`conversation.chat.assistant-actions` slot(该回合最终回复的操作栏)。点击 🔊
|
|
151
|
+
调 `/dsh-speak/control` 重播该条最终回复,再点停止,点另一条切换;按钮状态
|
|
152
|
+
(播放中/暂停)由 `/dsh-speak/ws` WebSocket 的 host 权威状态推导(session +
|
|
153
|
+
turn 身份匹配)。
|
|
154
|
+
- **设置 → dsh-speak 设置独立设置页**(1.7.0):注册进 `settings.section` slot。
|
|
155
|
+
用 `@deepseek-ai/dsh-client-ui-primitives` 的 Button/DisclosureRow/Input
|
|
156
|
+
绘制(Toggle/Options/SettingInput 组件),所有配置项(总开关、自动朗读、
|
|
157
|
+
queueAllMessages、Markdown 清洗、代码块、maxChars、longTextMode、固定提示语、
|
|
158
|
+
审批/提问、5 类可选事件)都通过 `settingsScope.bind({ namespace: 'dsh-speak' })`
|
|
159
|
+
读写。
|
|
160
|
+
|
|
161
|
+
- 包通过 `package.json` 的 `dsh.client: { platform: 'web' }` +
|
|
162
|
+
`exports['./client']` 声明浏览器端;DSH 的 client-modules 扫描到后自动加载。
|
|
163
|
+
- **刻意手写、零构建**:只用平台 seed 模块 + 官方 primitives(bundle-purity
|
|
164
|
+
gate 允许用 primitives,禁止 import 官方包内部组件),与构建出来的 bundle
|
|
165
|
+
契约一致。
|
|
166
|
+
|
|
128
167
|
### 3.3 Claude Code 适配层 — `adapters/claude-code/stop-hook.ps1`
|
|
129
168
|
|
|
130
169
|
Claude Code *确实*有 Stop hook。hook JSON(含 `transcript_path`)从 stdin 传入;
|
|
@@ -137,11 +176,19 @@ Claude Code *确实*有 Stop hook。hook JSON(含 `transcript_path`)从 stdi
|
|
|
137
176
|
| assistant 轮次 / 事件 | 是否播报 |
|
|
138
177
|
| -------------------------------- | -------- |
|
|
139
178
|
| 最终文本回复,无工具调用 | ✅ 节流后播报 |
|
|
140
|
-
| 文本 + tool/call(s) |
|
|
141
|
-
| 文本 + `ask_user_question` 调用 | ✅
|
|
179
|
+
| 文本 + tool/call(s) | 🟡 节流被取消(中间轮次);**回合结束时兜底补播最终回复** |
|
|
180
|
+
| 文本 + `ask_user_question` 调用 | ✅ 每个问题单独播报:"问题N"(多问题时)+ "选项N"序号,问题间停顿 `questionGapMs` |
|
|
142
181
|
| `approval/asked`(审批请求) | ✅ 立即播报(审批原因,否则固定提示语) |
|
|
143
182
|
| 只有 reasoning,无文本 | ❌(无 text 块) |
|
|
144
183
|
| 流式分块 | ❌(被过滤) |
|
|
184
|
+
| `turn/end`(回合结束) | 🟡 默认关;开则播报"第 N 轮对话完成/中断/异常结束" |
|
|
185
|
+
| `command/done`(命令完成) | 🟡 默认关;开则播报"命令执行完成/失败" |
|
|
186
|
+
| `goal/change`(目标变更) | 🟡 默认关;开则播报"已创建目标/目标已完成…(前 40 字)" |
|
|
187
|
+
| `tool/result`(工具结果) | 🟡 默认关;开则仅当带 `error` 或 `isError` 内容块时播报"工具调用出错"(英文详情/技术 code 截掉,只保留中文详情) |
|
|
188
|
+
| `todo/write`(待办更新) | 🟡 默认关;开则播报"待办已更新:n/m 完成" |
|
|
189
|
+
|
|
190
|
+
| `assistant/message`(queueAllMessages 开)| ✅ 每条立即入队(中间消息也读) |
|
|
191
|
+
| 手动重播(每条消息 🔊 按钮) | ✅ 清队列 → 停当前 → 读该回合 |
|
|
145
192
|
|
|
146
193
|
## 5. 配置参考
|
|
147
194
|
|
|
@@ -153,15 +200,52 @@ Claude Code *确实*有 Stop hook。hook JSON(含 `transcript_path`)从 stdi
|
|
|
153
200
|
| `-File` | `''` | 要读取的 UTF-8 文件 |
|
|
154
201
|
| `-Volume` | `50` | 0–100 |
|
|
155
202
|
| `-Rate` | `1` | 语速(SAPI 刻度) |
|
|
156
|
-
| `-MaxChars` |
|
|
203
|
+
| `-MaxChars` | 平台相关 | 超过此长度时替换为 `LongTextMessage`(macOS 默认 0 = 不限) |
|
|
157
204
|
| `-LongTextMessage` | `本次播报内容较长,请自行阅读。` | 超长文本时改念这句 |
|
|
205
|
+
| `-LongTextMode` | `message` | `message`(固定提示语)\| `heading`(念最大字号 markdown 标题) |
|
|
206
|
+
| `-CleanMarkdownFormatting` | `true` | Markdown 转自然语音(保留链接文字去 URL) |
|
|
207
|
+
| `-ReadInlineCode` | `true` | 朗读行内代码(去掉反引号) |
|
|
208
|
+
| `-CodeBlocks` | `smart` | `all` \| `smart` \| `replace`(围栏代码块) |
|
|
209
|
+
| `-CodeBlockMaxChars` | `300` | `smart` 模式的代码块字数上限 |
|
|
210
|
+
| `-CodeBlockReplacementText` | `You can see the code in our history.` | `replace` 时的替代文本 |
|
|
158
211
|
|
|
159
|
-
### DSH
|
|
212
|
+
### DSH 插件(profile `config`;1.7.0 起同样可在 Web UI 的 dsh-speak 设置页里改)
|
|
160
213
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
214
|
+
```yaml
|
|
215
|
+
config:
|
|
216
|
+
enabled: true # 总开关
|
|
217
|
+
automaticSpeech: true # 自动朗读最终回复
|
|
218
|
+
queueAllMessages: false # true = 所有 assistant 消息立即入队
|
|
219
|
+
replayFullRead: false # true = 手动重播跳过超长文本截断,完整朗读
|
|
220
|
+
cleanMarkdownFormatting: true
|
|
221
|
+
readInlineCode: true
|
|
222
|
+
codeBlocks: smart # all | smart | replace
|
|
223
|
+
codeBlockMaxChars: 300
|
|
224
|
+
codeBlockReplacementText: 'You can see the code in our history.'
|
|
225
|
+
throttleMs: 1500
|
|
226
|
+
engine: '' # '' = 自动解析
|
|
227
|
+
announceApprovals: true
|
|
228
|
+
announceQuestions: true
|
|
229
|
+
stripApprovalPrefix: true
|
|
230
|
+
questionGapMs: 2000 # 多个提问播报之间的停顿(毫秒)
|
|
231
|
+
longTextMode: message # message | heading
|
|
232
|
+
longTextMessage: '本次播报内容较长,请自行阅读。'
|
|
233
|
+
maxChars: 300 # macOS 默认 0 = 不限
|
|
234
|
+
volume: 50 # 仅 Windows
|
|
235
|
+
rate: 0 # 0 = 引擎默认
|
|
236
|
+
# —— 可选事件播报(默认全关)——
|
|
237
|
+
announceTurnEnd: false # turn/end
|
|
238
|
+
announceCommandDone: false # command/done
|
|
239
|
+
announceGoalChange: false # goal/change
|
|
240
|
+
announceToolErrors: false # tool/result 带 error 或 isError 块时
|
|
241
|
+
announceTodoWrite: false # todo/write
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
配置解析顺序:schema 默认值 → patch `config`(base)→ UI 用户设置(user 层)。
|
|
245
|
+
浏览器端 dsh-speak 设置页(`client/client.js`)与 patch YAML 读写同一个 settings
|
|
246
|
+
文档。平台差异:`maxChars` macOS 默认 0(`say` 无上限)、Windows 默认 300。
|
|
247
|
+
|
|
248
|
+
完整配置指南见 README 的"配置"一节。
|
|
165
249
|
|
|
166
250
|
## 6. 踩坑记录(来之不易;不要随意"修复")
|
|
167
251
|
|
|
@@ -189,10 +273,9 @@ hook)、任意 shell harness(Agent 自己调 `speech-summary.ps1`)就是
|
|
|
189
273
|
|
|
190
274
|
## 8. 项目定位
|
|
191
275
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
本仓库保持为最小、自包含的参考实现。
|
|
276
|
+
本项目保持**小而自包含**的设计:一个小引擎 + 两种可复用的适配范式(事件流 /
|
|
277
|
+
Stop hook),并**持续维护**。需要更多能力(音色管理界面、更多后端、跨平台)时,
|
|
278
|
+
把引擎当作接缝在其上扩展。
|
|
196
279
|
|
|
197
280
|
## 9. 发布为 npm 插件(附录)
|
|
198
281
|
|
|
@@ -212,7 +295,7 @@ DSH 的插件机制基于 Cordis,官方安装树外插件的路径是
|
|
|
212
295
|
|
|
213
296
|
`speech-hook.js` 按以下顺序定位 `engine/speak.ps1`:
|
|
214
297
|
|
|
215
|
-
1. `
|
|
298
|
+
1. `config.engine` 覆盖;
|
|
216
299
|
2. 相对插件文件解析 `<包>/engine/speak.ps1`——同时覆盖仓库检出和
|
|
217
300
|
`npm install` 后的 `node_modules/dsh-speak/`;
|
|
218
301
|
3. 旧的 `%USERPROFILE%\.dsh\hooks\speak.ps1`(文件安装的位置)。
|
package/engine/speak.ps1
CHANGED
|
@@ -29,9 +29,27 @@ param(
|
|
|
29
29
|
[int]$Volume = 50,
|
|
30
30
|
[int]$Rate = 1,
|
|
31
31
|
[int]$MaxChars = 300,
|
|
32
|
-
[string]$LongTextMessage = '本次播报内容较长,请自行阅读。'
|
|
32
|
+
[string]$LongTextMessage = '本次播报内容较长,请自行阅读。',
|
|
33
|
+
[ValidateSet('message', 'heading')]
|
|
34
|
+
[string]$LongTextMode = 'message',
|
|
35
|
+
# 命令行参数一律是字符串(-File 模式不做类型转换),这里用 [string] 接收,
|
|
36
|
+
# 在脚本内部再转布尔,兼容 '1'/'0'/'true'/'True'/'yes'/'on' 等写法。
|
|
37
|
+
[string]$CleanMarkdownFormatting = 'true',
|
|
38
|
+
[string]$ReadInlineCode = 'true',
|
|
39
|
+
[ValidateSet('all', 'smart', 'replace')]
|
|
40
|
+
[string]$CodeBlocks = 'smart',
|
|
41
|
+
[int]$CodeBlockMaxChars = 300,
|
|
42
|
+
[string]$CodeBlockReplacementText = 'You can see the code in our history.',
|
|
43
|
+
# 手动重播完整朗读:跳过超长文本的 heading/message 截断,分段完整朗读
|
|
44
|
+
[string]$FullRead = '0'
|
|
33
45
|
)
|
|
34
46
|
|
|
47
|
+
$cleanMarkdown = $CleanMarkdownFormatting -in @('1', 'true', 'yes', 'on')
|
|
48
|
+
$readInlineCode = $ReadInlineCode -in @('1', 'true', 'yes', 'on')
|
|
49
|
+
# 注意:PowerShell 变量大小写不敏感,内部变量名不能与参数名仅差大小写
|
|
50
|
+
# (曾用 $fullRead 导致自赋值污染参数 $FullRead,使 -not 判断失效)
|
|
51
|
+
$fullReadMode = $FullRead -in @('1', 'true', 'yes', 'on')
|
|
52
|
+
|
|
35
53
|
# ---------- input: pick text source ----------
|
|
36
54
|
if ($File) {
|
|
37
55
|
if (-not (Test-Path $File)) { exit 0 }
|
|
@@ -41,21 +59,52 @@ if ($File) {
|
|
|
41
59
|
}
|
|
42
60
|
if (-not $text -or -not $text.Trim()) { exit 0 }
|
|
43
61
|
|
|
44
|
-
# ----------
|
|
45
|
-
#
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
$
|
|
50
|
-
$
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
$
|
|
62
|
+
# ---------- length guard: adapter per-Speak ceiling ----------
|
|
63
|
+
# 'message': fixed prompt. 'heading': speak the largest markdown heading instead
|
|
64
|
+
# (fewest '#' wins, tie -> first; no heading -> first non-empty line; the
|
|
65
|
+
# cleaned candidate is still subject to the ceiling below). FullRead 手动重播
|
|
66
|
+
# 跳过该守卫(见文件底部"完整朗读"分支)。
|
|
67
|
+
if (-not $fullReadMode -and $text.Length -gt $MaxChars -and $LongTextMode -eq 'heading') {
|
|
68
|
+
$candidate = ''
|
|
69
|
+
$bestLevel = 7
|
|
70
|
+
$firstNonEmpty = ''
|
|
71
|
+
foreach ($line in ($text -split "`n")) {
|
|
72
|
+
if ($line -match '^\s*#{1,6}\s+') {
|
|
73
|
+
$level = ([regex]::Match($line, '^(\s*)(#+)')).Groups[2].Value.Length
|
|
74
|
+
if ($level -lt $bestLevel) {
|
|
75
|
+
$bestLevel = $level
|
|
76
|
+
$candidate = $line -replace '^\s*#+\s*', ''
|
|
77
|
+
}
|
|
78
|
+
} elseif (-not $firstNonEmpty -and $line.Trim()) {
|
|
79
|
+
$firstNonEmpty = $line
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (-not $candidate) { $candidate = $firstNonEmpty }
|
|
83
|
+
if ($candidate) { $text = $candidate }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# ---------- clean: Markdown -> natural speech text ----------
|
|
87
|
+
if ($cleanMarkdown) {
|
|
88
|
+
$text = [regex]::Replace($text, '```[^\n]*\n?([\s\S]*?)```', {
|
|
89
|
+
param($match)
|
|
90
|
+
$code = $match.Groups[1].Value
|
|
91
|
+
if ($CodeBlocks -eq 'all' -or ($CodeBlocks -eq 'smart' -and $code.Length -le $CodeBlockMaxChars)) { return " $code " }
|
|
92
|
+
return " $CodeBlockReplacementText "
|
|
93
|
+
})
|
|
94
|
+
if ($readInlineCode) { $text = $text -replace '`([^`]*)`', '$1' } else { $text = $text -replace '`[^`]*`', ' ' }
|
|
95
|
+
$text = $text -replace '\[([^\]]*)\]\([^\)]*\)', '$1'
|
|
96
|
+
$text = $text -replace 'https?://\S+', ' '
|
|
97
|
+
$text = $text -replace '(?m)^\s{0,3}(?:#{1,6}\s+|[-*+]\s+|\d+[.)]\s+|>\s?)', ' '
|
|
98
|
+
$text = $text -replace '(\*\*|__|~~)(.*?)\1', '$2'
|
|
99
|
+
$text = $text -replace '[*_~]+', ''
|
|
100
|
+
}
|
|
101
|
+
# Keep all Unicode letters, including Portuguese accents; remove unsafe symbols.
|
|
102
|
+
$text = [regex]::Replace($text, '[^\p{L}\p{N}一-龥 -〿- - -~]', '')
|
|
54
103
|
$text = $text -replace '\s+', ' '
|
|
55
104
|
$text = $text.Trim()
|
|
56
105
|
|
|
57
|
-
# ----------
|
|
58
|
-
if ($text.Length -gt $MaxChars) { $text = $LongTextMessage }
|
|
106
|
+
# ---------- final ceiling (also catches over-long heading candidates) ----------
|
|
107
|
+
if (-not $fullReadMode -and $text.Length -gt $MaxChars) { $text = $LongTextMessage }
|
|
59
108
|
|
|
60
109
|
# ---------- speak ----------
|
|
61
110
|
Add-Type -AssemblyName System.Speech
|
|
@@ -72,5 +121,30 @@ if (-not $voice) { $voice = $voices | Where-Object { $_.VoiceInfo.Culture.Name -
|
|
|
72
121
|
if ($voice) { $synth.SelectVoice($voice.VoiceInfo.Name) }
|
|
73
122
|
|
|
74
123
|
$synth.Rate = $Rate
|
|
75
|
-
|
|
124
|
+
|
|
125
|
+
# ---------- 完整朗读(FullRead,手动重播) ----------
|
|
126
|
+
# Windows SAPI 单次 Speak 有约 375-470 字上限,超长会静默失败;因此按句末
|
|
127
|
+
# 标点切成不超过 450 字的段,逐段朗读(自动播报不经过这里,走上面的守卫)。
|
|
128
|
+
$SPEAK_CHUNK = 400
|
|
129
|
+
if ($fullReadMode -and $text.Length -gt $SPEAK_CHUNK) {
|
|
130
|
+
$parts = [regex]::Split($text, '(?<=[。!?;.!?;])')
|
|
131
|
+
$chunk = ''
|
|
132
|
+
foreach ($part in $parts) {
|
|
133
|
+
if ($part.Length -eq 0) { continue }
|
|
134
|
+
if ($chunk.Length + $part.Length -gt $SPEAK_CHUNK) {
|
|
135
|
+
if ($chunk) { $synth.Speak($chunk); $chunk = '' }
|
|
136
|
+
# 单段仍超长:硬切
|
|
137
|
+
while ($part.Length -gt $SPEAK_CHUNK) {
|
|
138
|
+
$synth.Speak($part.Substring(0, $SPEAK_CHUNK))
|
|
139
|
+
$part = $part.Substring($SPEAK_CHUNK)
|
|
140
|
+
}
|
|
141
|
+
$chunk = $part
|
|
142
|
+
} else {
|
|
143
|
+
$chunk += $part
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if ($chunk) { $synth.Speak($chunk) }
|
|
147
|
+
} else {
|
|
148
|
+
$synth.Speak($text)
|
|
149
|
+
}
|
|
76
150
|
exit 0
|
package/engine/speak.sh
CHANGED
|
@@ -1,49 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# speak.sh — macOS speech engine (uses the built-in `say` command)
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# ./speak.sh -t "你好,构建完成"
|
|
9
|
-
# ./speak.sh -f /tmp/msg.txt -v Eddy -r 200
|
|
10
|
-
#
|
|
11
|
-
# Best-effort by design: never throws, exits 0 even if something failed.
|
|
12
|
-
#
|
|
13
|
-
# Voice selection:
|
|
14
|
-
# * no -v (default): follow the system voice — on recent macOS this is the
|
|
15
|
-
# Siri voice chosen in Settings > Siri > Voice (e.g. "声音 1"), on older
|
|
16
|
-
# versions the Spoken Content voice. This is the least surprising default.
|
|
17
|
-
# * -v <name>: force a voice by name (e.g. Eddy, Flo, Tingting — see
|
|
18
|
-
# `say -v '?'`).
|
|
19
|
-
# * NOTE: the Siri voices ("声音 1-4") are NOT exposed to `say` — they do not
|
|
20
|
-
# appear in `say -v '?'` and cannot be selected by name; they only work as
|
|
21
|
-
# the system default.
|
|
22
|
-
#
|
|
23
|
-
# Notes:
|
|
24
|
-
# * LC_ALL is pinned to a UTF-8 locale so the perl cleaning pipeline and bash
|
|
25
|
-
# character counting behave identically regardless of the caller's locale
|
|
26
|
-
# (a C/POSIX locale would silently strip all CJK text).
|
|
27
|
-
# * The cleaning pipeline mirrors speak.ps1 (markdown/URL/emoji stripped).
|
|
28
|
-
# * `say` has no volume flag — volume is controlled by the system output.
|
|
29
|
-
# * Length guard: text over $MAX_CHARS is replaced with $LONG_MSG.
|
|
30
|
-
# ==============================================================================
|
|
3
|
+
# ==================================================================
|
|
4
|
+
# Reads text (inline or UTF-8 file), optionally converts Markdown into natural
|
|
5
|
+
# speech text, and reads it aloud with the system voice. A zero max length means
|
|
6
|
+
# unlimited text, which is safe for macOS `say`.
|
|
31
7
|
|
|
32
8
|
export LC_ALL="${LC_ALL:-en_US.UTF-8}"
|
|
33
9
|
|
|
34
10
|
TEXT=""
|
|
35
11
|
FILE=""
|
|
36
12
|
VOICE=""
|
|
37
|
-
RATE=175
|
|
38
|
-
MAX_CHARS=
|
|
13
|
+
RATE=175
|
|
14
|
+
MAX_CHARS=0
|
|
39
15
|
LONG_MSG="本次播报内容较长,请自行阅读。"
|
|
16
|
+
LONG_MODE="message"
|
|
17
|
+
CLEAN_MARKDOWN=1
|
|
18
|
+
READ_INLINE_CODE=1
|
|
19
|
+
CODE_BLOCKS="smart"
|
|
20
|
+
CODE_BLOCK_MAX_CHARS=300
|
|
21
|
+
CODE_BLOCK_REPLACEMENT="You can see the code in our history."
|
|
22
|
+
FULL_READ=0
|
|
40
23
|
|
|
41
24
|
usage() {
|
|
42
|
-
echo "usage: speak.sh [-t text | -f file] [-v voice] [-r wpm] [-m maxchars] [-l longmsg]" >&2
|
|
25
|
+
echo "usage: speak.sh [-t text | -f file] [-v voice] [-r wpm] [-m maxchars] [-l longmsg] [-M message|heading] [-C 0|1] [-I 0|1] [-B all|smart|replace] [-K codechars] [-R replacement] [-F]" >&2
|
|
43
26
|
exit 1
|
|
44
27
|
}
|
|
45
28
|
|
|
46
|
-
while getopts "t:f:v:r:m:l:
|
|
29
|
+
while getopts "t:f:v:r:m:l:M:C:I:B:K:R:Fh" opt; do
|
|
47
30
|
case "$opt" in
|
|
48
31
|
t) TEXT="$OPTARG" ;;
|
|
49
32
|
f) FILE="$OPTARG" ;;
|
|
@@ -51,39 +34,70 @@ while getopts "t:f:v:r:m:l:h" opt; do
|
|
|
51
34
|
r) RATE="$OPTARG" ;;
|
|
52
35
|
m) MAX_CHARS="$OPTARG" ;;
|
|
53
36
|
l) LONG_MSG="$OPTARG" ;;
|
|
37
|
+
M) LONG_MODE="$OPTARG" ;;
|
|
38
|
+
C) CLEAN_MARKDOWN="$OPTARG" ;;
|
|
39
|
+
I) READ_INLINE_CODE="$OPTARG" ;;
|
|
40
|
+
B) CODE_BLOCKS="$OPTARG" ;;
|
|
41
|
+
K) CODE_BLOCK_MAX_CHARS="$OPTARG" ;;
|
|
42
|
+
R) CODE_BLOCK_REPLACEMENT="$OPTARG" ;;
|
|
43
|
+
F) FULL_READ=1 ;;
|
|
54
44
|
h) usage ;;
|
|
55
45
|
*) usage ;;
|
|
56
46
|
esac
|
|
57
47
|
done
|
|
58
48
|
|
|
59
|
-
# ---------- input ----------
|
|
60
49
|
if [ -n "$FILE" ]; then
|
|
61
50
|
[ -f "$FILE" ] || exit 0
|
|
62
51
|
TEXT=$(/usr/bin/perl -CSD -e 'print <>' "$FILE")
|
|
63
52
|
fi
|
|
64
|
-
|
|
53
|
+
[ -n "$TEXT" ] || exit 0
|
|
65
54
|
|
|
66
|
-
#
|
|
55
|
+
# `heading` is meaningful only when a positive long-text ceiling is configured.
|
|
56
|
+
# -F (full read, manual replay) skips this guard entirely.
|
|
57
|
+
if [ "$FULL_READ" != "1" ] && [ "$MAX_CHARS" -gt 0 ] && [ "${#TEXT}" -gt "$MAX_CHARS" ] && [ "$LONG_MODE" = "heading" ]; then
|
|
58
|
+
TEXT=$(printf '%s' "$TEXT" | /usr/bin/perl -CSD -e '
|
|
59
|
+
my $best = 7; my $cand = ""; my $first = "";
|
|
60
|
+
while (<STDIN>) {
|
|
61
|
+
if (/^[ \t]*(\#{1,6})[ \t]+(.*)$/) { my $n = length($1); if ($n < $best) { $best = $n; $cand = $2; } }
|
|
62
|
+
elsif ($first eq "" && /\S/) { $first = $_; }
|
|
63
|
+
}
|
|
64
|
+
print($cand eq "" ? $first : $cand);
|
|
65
|
+
')
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
if [ "$CLEAN_MARKDOWN" = "1" ]; then
|
|
69
|
+
export DSH_SPEAK_CODE_BLOCKS="$CODE_BLOCKS"
|
|
70
|
+
export DSH_SPEAK_CODE_BLOCK_MAX_CHARS="$CODE_BLOCK_MAX_CHARS"
|
|
71
|
+
export DSH_SPEAK_CODE_BLOCK_REPLACEMENT="$CODE_BLOCK_REPLACEMENT"
|
|
72
|
+
export DSH_SPEAK_READ_INLINE_CODE="$READ_INLINE_CODE"
|
|
73
|
+
TEXT=$(printf '%s' "$TEXT" | /usr/bin/perl -CSD -0pe '
|
|
74
|
+
my $mode = $ENV{DSH_SPEAK_CODE_BLOCKS} || "smart";
|
|
75
|
+
my $limit = $ENV{DSH_SPEAK_CODE_BLOCK_MAX_CHARS} || 300;
|
|
76
|
+
my $replacement = $ENV{DSH_SPEAK_CODE_BLOCK_REPLACEMENT} || "";
|
|
77
|
+
s{```[^\n]*\n?(.*?)```}{
|
|
78
|
+
my $code = $1;
|
|
79
|
+
$mode eq "all" || ($mode eq "smart" && length($code) <= $limit) ? " $code " : " $replacement ";
|
|
80
|
+
}gse;
|
|
81
|
+
if (($ENV{DSH_SPEAK_READ_INLINE_CODE} || "1") eq "1") { s/`([^`]*)`/$1/g; }
|
|
82
|
+
else { s/`[^`]*`/ /g; }
|
|
83
|
+
s/\[([^\]]*)\]\([^\)]*\)/$1/g;
|
|
84
|
+
s|https?://\S+| |g;
|
|
85
|
+
s/^\s{0,3}(?:\#{1,6}\s+|[-*+]\s+|\d+[.)]\s+|>\s?)/ /gm;
|
|
86
|
+
s/(\*\*|__|~~)(.*?)\1/$2/g;
|
|
87
|
+
s/[\*_~]+//g;
|
|
88
|
+
')
|
|
89
|
+
fi
|
|
90
|
+
|
|
91
|
+
# Retain all letters (including Portuguese accents) and numbers, while removing
|
|
92
|
+
# emoji/symbols that make the native synthesizer unreliable.
|
|
67
93
|
TEXT=$(printf '%s' "$TEXT" | /usr/bin/perl -CSD -pe '
|
|
68
|
-
s
|
|
69
|
-
s
|
|
70
|
-
s/\[([^\]]*)\]\([^\)]*\)/$1/g; # markdown links
|
|
71
|
-
s|https?://\S+| |g; # bare URLs
|
|
72
|
-
s/[-#*_~|>+]+/ /g; # emphasis / marker chars
|
|
73
|
-
s/[^\p{Han}\x{3000}-\x{303F}\x{FF00}-\x{FFEF}\x{2000}-\x{206F}\x{20}-\x{7E}]//g; # emoji / specials
|
|
74
|
-
s/\s+/ /g; # collapse whitespace
|
|
94
|
+
s/[^\p{L}\p{N}\p{Han}\x{3000}-\x{303F}\x{FF00}-\x{FFEF}\x{2000}-\x{206F}\x{20}-\x{7E}]//g;
|
|
95
|
+
s/\s+/ /g;
|
|
75
96
|
')
|
|
76
97
|
TEXT=$(printf '%s' "$TEXT" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
|
77
98
|
|
|
78
|
-
#
|
|
79
|
-
|
|
80
|
-
TEXT="$LONG_MSG"
|
|
81
|
-
fi
|
|
99
|
+
if [ "$FULL_READ" != "1" ] && [ "$MAX_CHARS" -gt 0 ] && [ "${#TEXT}" -gt "$MAX_CHARS" ]; then TEXT="$LONG_MSG"; fi
|
|
100
|
+
[ -n "$TEXT" ] || exit 0
|
|
82
101
|
|
|
83
|
-
|
|
84
|
-
if [ -n "$VOICE" ]; then
|
|
85
|
-
say -v "$VOICE" -r "$RATE" "$TEXT"
|
|
86
|
-
else
|
|
87
|
-
say -r "$RATE" "$TEXT"
|
|
88
|
-
fi
|
|
102
|
+
if [ -n "$VOICE" ]; then say -v "$VOICE" -r "$RATE" "$TEXT"; else say -r "$RATE" "$TEXT"; fi
|
|
89
103
|
exit 0
|
package/package.json
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-speak",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Make your AI harness speak —
|
|
3
|
+
"version": "1.7.0",
|
|
4
|
+
"description": "Make your AI harness speak — voice announcements for DSH and other AI coding harnesses (Windows SAPI5 + macOS system voices)",
|
|
5
5
|
"main": "adapters/dsh/speech-hook.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./adapters/dsh/speech-hook.js",
|
|
8
|
+
"./client": "./client/client.js",
|
|
9
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
6
12
|
"files": [
|
|
7
13
|
"adapters/dsh/speech-hook.js",
|
|
8
14
|
"adapters/dsh/install.ps1",
|
|
9
15
|
"cordis.patch.yml",
|
|
16
|
+
"client/",
|
|
10
17
|
"engine/",
|
|
11
18
|
"docs/",
|
|
12
19
|
"README.md",
|
|
@@ -16,6 +23,17 @@
|
|
|
16
23
|
"dsh": {
|
|
17
24
|
"bundle": {
|
|
18
25
|
"patch": "./cordis.patch.yml"
|
|
26
|
+
},
|
|
27
|
+
"client": {
|
|
28
|
+
"platform": "web",
|
|
29
|
+
"inject": [
|
|
30
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
31
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
32
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
33
|
+
],
|
|
34
|
+
"external": [
|
|
35
|
+
"@deepseek-ai/dsh-client-ui-primitives"
|
|
36
|
+
]
|
|
19
37
|
}
|
|
20
38
|
},
|
|
21
39
|
"keywords": [
|
|
@@ -35,7 +53,10 @@
|
|
|
35
53
|
"say"
|
|
36
54
|
],
|
|
37
55
|
"scripts": {
|
|
38
|
-
"prepublishOnly": "node --check adapters/dsh/speech-hook.js"
|
|
56
|
+
"prepublishOnly": "node --check adapters/dsh/speech-hook.js && node --check client/client.js"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"ws": "^8.18.0"
|
|
39
60
|
},
|
|
40
61
|
"engines": {
|
|
41
62
|
"node": ">=18"
|
|
@@ -46,6 +67,18 @@
|
|
|
46
67
|
},
|
|
47
68
|
"author": "Alan2Z",
|
|
48
69
|
"license": "MIT",
|
|
70
|
+
"peerDependencies": {
|
|
71
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
|
|
72
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
73
|
+
},
|
|
74
|
+
"peerDependenciesMeta": {
|
|
75
|
+
"@deepseek-ai/dsh-settings": {
|
|
76
|
+
"optional": true
|
|
77
|
+
},
|
|
78
|
+
"@deepseek-ai/schemastery": {
|
|
79
|
+
"optional": true
|
|
80
|
+
}
|
|
81
|
+
},
|
|
49
82
|
"publishConfig": {
|
|
50
83
|
"registry": "https://registry.npmjs.org"
|
|
51
84
|
}
|