dsh-speak 1.8.0 → 1.8.1
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/LICENSE +21 -21
- package/README.md +462 -458
- package/README.zh-CN.md +433 -430
- package/adapters/dsh/install.ps1 +81 -81
- package/adapters/dsh/speech-hook.js +614 -614
- package/client/client.js +357 -357
- package/docs/DESIGN.md +22 -0
- package/docs/DESIGN.zh-CN.md +18 -0
- package/engine/speak.ps1 +214 -214
- package/engine/speak.sh +0 -0
- package/engine/speech-prompt.ps1 +24 -24
- package/engine/speech-summary.ps1 +27 -27
- package/package.json +82 -81
package/docs/DESIGN.md
CHANGED
|
@@ -351,6 +351,28 @@ dependencies in the profile). This repository is prepared for that path:
|
|
|
351
351
|
Because the engine rides inside the npm package, `dsh plugin --profile web add
|
|
352
352
|
dsh-speak` alone is sufficient — no separate copying step.
|
|
353
353
|
|
|
354
|
+
### Host requirement (`engines.dsh`)
|
|
355
|
+
|
|
356
|
+
`package.json` declares `engines.dsh: >=0.1.5-rc.1`. That one field is what
|
|
357
|
+
dsh-market reads to label the catalog card (`DSH >=0.1.5-rc.1`) and to decide
|
|
358
|
+
whether the plugin survives its "compatible with current DSH" filter:
|
|
359
|
+
|
|
360
|
+
- the facts come from the package's npm `latest` manifest
|
|
361
|
+
(`{registry}/<pkg>/latest`, cached ~24 h) — the catalog YAML in
|
|
362
|
+
`awesome-dsh-plugin` has no host field, so a PR there cannot declare this;
|
|
363
|
+
- an `engines.dsh` value is an `engine` declaration. Lockstep `@deepseek-ai/dsh*`
|
|
364
|
+
**peers** count too, but non-lockstep host packages are skipped on purpose
|
|
365
|
+
(`@deepseek-ai/schemastery`, `@deepseek-ai/cordis` — the schemastery peer above
|
|
366
|
+
therefore declares nothing about the DSH version);
|
|
367
|
+
- all declarations are conjunctive and compared prerelease-aware, so
|
|
368
|
+
`>=0.1.5-rc.1` matches a `0.1.5-rc.1` host; a missing declaration shows up as
|
|
369
|
+
"host requirement undeclared", never as "incompatible";
|
|
370
|
+
- npm itself only enforces `engines.node` / `engines.npm`, so this key never
|
|
371
|
+
blocks an install — it is marketplace metadata;
|
|
372
|
+
- move the floor only after a release has been verified against the new host, and
|
|
373
|
+
update both READMEs with it: `scripts/test-manifest.js` asserts the same string
|
|
374
|
+
appears in `package.json`, `README.md` and `README.zh-CN.md`.
|
|
375
|
+
|
|
354
376
|
### Publish steps (maintainer)
|
|
355
377
|
|
|
356
378
|
```powershell
|
package/docs/DESIGN.zh-CN.md
CHANGED
|
@@ -321,6 +321,24 @@ DSH 的插件机制基于 Cordis,官方安装树外插件的路径是
|
|
|
321
321
|
因为引擎随 npm 包分发,用户只需 `dsh plugin --profile web add dsh-speak`
|
|
322
322
|
一条命令,无需额外拷贝。
|
|
323
323
|
|
|
324
|
+
### 宿主要求(`engines.dsh`)
|
|
325
|
+
|
|
326
|
+
`package.json` 声明了 `engines.dsh: >=0.1.5-rc.1`。dsh-market 只读这一个字段来标注
|
|
327
|
+
目录卡片(`DSH >=0.1.5-rc.1`)并决定插件能否通过「适配当前 DSH」筛选:
|
|
328
|
+
|
|
329
|
+
- 数据源是该包 npm `latest` manifest(`{registry}/<pkg>/latest`,缓存约 24 小时)
|
|
330
|
+
——`awesome-dsh-plugin` 里那份目录 YAML 没有宿主字段,往那里提 PR 声明不了;
|
|
331
|
+
- `engines.dsh` 属于 `engine` 声明;同版本线的 `@deepseek-ai/dsh*` **peer** 也算,
|
|
332
|
+
但非同一版本线的宿主包会被有意跳过(`@deepseek-ai/schemastery`、
|
|
333
|
+
`@deepseek-ai/cordis`)——所以上面那条 schemastery peer 不构成任何 DSH 版本声明;
|
|
334
|
+
- 所有声明取交集,比较时带 prerelease 语义,`>=0.1.5-rc.1` 能匹配 `0.1.5-rc.1`
|
|
335
|
+
宿主;完全没声明只会显示「未声明宿主要求」,不会显示成不兼容;
|
|
336
|
+
- npm 本身只强制 `engines.node` / `engines.npm`,这个键不会挡住安装,它只是市场
|
|
337
|
+
元数据;
|
|
338
|
+
- 只有在某个宿主版本上实测通过后才移动下限,并同步两份 README:
|
|
339
|
+
`scripts/test-manifest.js` 断言同一个字符串同时出现在 `package.json`、
|
|
340
|
+
`README.md`、`README.zh-CN.md`。
|
|
341
|
+
|
|
324
342
|
### 发布步骤(维护者)
|
|
325
343
|
|
|
326
344
|
```powershell
|
package/engine/speak.ps1
CHANGED
|
@@ -1,214 +1,214 @@
|
|
|
1
|
-
# speak.ps1 — Harness-agnostic speech engine (Windows SAPI5 + NaturalVoiceSAPIAdapter)
|
|
2
|
-
# ====================================================================================
|
|
3
|
-
# Reads text (inline or from a UTF-8 file), cleans it for speech synthesis, and reads
|
|
4
|
-
# it aloud through Windows SAPI5, preferring natural voices registered by
|
|
5
|
-
# NaturalVoiceSAPIAdapter (https://github.com/gexgd0419/NaturalVoiceSAPIAdapter).
|
|
6
|
-
#
|
|
7
|
-
# This script knows NOTHING about any harness (DSH, Claude Code, ...). Any process
|
|
8
|
-
# can call it:
|
|
9
|
-
#
|
|
10
|
-
# powershell.exe -NoProfile -ExecutionPolicy Bypass -File speak.ps1 -Text "hello"
|
|
11
|
-
# powershell.exe -NoProfile -ExecutionPolicy Bypass -File speak.ps1 -File C:\tmp\msg.txt
|
|
12
|
-
#
|
|
13
|
-
# It is best-effort by design: it never throws, never blocks the caller for longer
|
|
14
|
-
# than the utterance itself, and exits 0 even if something failed.
|
|
15
|
-
#
|
|
16
|
-
# Design notes (see docs/DESIGN.md for full rationale):
|
|
17
|
-
# * Markdown symbols, URLs and emoji are stripped before speaking — SAPI5 Speak()
|
|
18
|
-
# silently fails (produces no audio, no error) when it hits emoji/surrogates.
|
|
19
|
-
# * NaturalVoiceSAPIAdapter has a per-Speak character ceiling (~375-470 chars);
|
|
20
|
-
# beyond that it silently speaks nothing. Text longer than $MaxChars is replaced
|
|
21
|
-
# with $LongTextMessage instead.
|
|
22
|
-
# * Adapter-registered voices often have plain names ("Microsoft Xiaoxiao") that do
|
|
23
|
-
# not contain the word "Natural", so matching checks Name + Description.
|
|
24
|
-
# ====================================================================================
|
|
25
|
-
|
|
26
|
-
param(
|
|
27
|
-
[string]$Text = '',
|
|
28
|
-
[string]$File = '',
|
|
29
|
-
[int]$Volume = 50,
|
|
30
|
-
[int]$Rate = 1,
|
|
31
|
-
[int]$MaxChars = 300,
|
|
32
|
-
# 本脚本唯一的非 ASCII 代码字面量。丢了 UTF-8 BOM 时 PowerShell 5.1 会按 ANSI
|
|
33
|
-
# 代码页把它解码成乱码——DSH 插件总是显式传 -LongTextMessage,所以只影响手动
|
|
34
|
-
# CLI 调用;脚本逻辑(句末判定 / 字符过滤 / 分句)已全部改成纯 ASCII 源码。
|
|
35
|
-
[string]$LongTextMessage = '本次播报内容较长,请自行阅读。',
|
|
36
|
-
[ValidateSet('message', 'heading')]
|
|
37
|
-
[string]$LongTextMode = 'message',
|
|
38
|
-
# 命令行参数一律是字符串(-File 模式不做类型转换),这里用 [string] 接收,
|
|
39
|
-
# 在脚本内部再转布尔,兼容 '1'/'0'/'true'/'True'/'yes'/'on' 等写法。
|
|
40
|
-
[string]$CleanMarkdownFormatting = 'true',
|
|
41
|
-
[string]$ReadInlineCode = 'true',
|
|
42
|
-
[ValidateSet('all', 'smart', 'replace')]
|
|
43
|
-
[string]$CodeBlocks = 'smart',
|
|
44
|
-
[int]$CodeBlockMaxChars = 300,
|
|
45
|
-
[string]$CodeBlockReplacementText = 'You can see the code in our history.',
|
|
46
|
-
# 手动重播完整朗读:跳过超长文本的 heading/message 截断,分段完整朗读
|
|
47
|
-
[string]$FullRead = '0',
|
|
48
|
-
# 只把「将要朗读的文本」按 UTF-8 写到 stdout、完全不出声(调试清洗与长文
|
|
49
|
-
# 守卫用;正常调用无需传,插件不会传)
|
|
50
|
-
[string]$DryRun = '0'
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
$cleanMarkdown = $CleanMarkdownFormatting -in @('1', 'true', 'yes', 'on')
|
|
54
|
-
$readInlineCode = $ReadInlineCode -in @('1', 'true', 'yes', 'on')
|
|
55
|
-
$dryRunMode = $DryRun -in @('1', 'true', 'yes', 'on')
|
|
56
|
-
# 注意:PowerShell 变量大小写不敏感,内部变量名不能与参数名仅差大小写
|
|
57
|
-
# (曾用 $fullRead 导致自赋值污染参数 $FullRead,使 -not 判断失效)
|
|
58
|
-
$fullReadMode = $FullRead -in @('1', 'true', 'yes', 'on')
|
|
59
|
-
|
|
60
|
-
# ---------- input: pick text source ----------
|
|
61
|
-
if ($File) {
|
|
62
|
-
if (-not (Test-Path $File)) { exit 0 }
|
|
63
|
-
$text = [System.IO.File]::ReadAllText($File, [System.Text.Encoding]::UTF8)
|
|
64
|
-
} else {
|
|
65
|
-
$text = [string]$Text
|
|
66
|
-
}
|
|
67
|
-
if (-not $text -or -not $text.Trim()) { exit 0 }
|
|
68
|
-
|
|
69
|
-
# ---------- length guard: adapter per-Speak ceiling ----------
|
|
70
|
-
# 'message': fixed prompt. 'heading': speak the largest markdown heading instead
|
|
71
|
-
# (fewest '#' wins, tie -> first; with NO heading, speak a coherent opening of
|
|
72
|
-
# the text — see below; the cleaned candidate is still subject to the ceiling
|
|
73
|
-
# below). FullRead 手动重播跳过该守卫(见文件底部"完整朗读"分支)。
|
|
74
|
-
if (-not $fullReadMode -and $MaxChars -gt 0 -and $text.Length -gt $MaxChars -and $LongTextMode -eq 'heading') {
|
|
75
|
-
$candidate = ''
|
|
76
|
-
$bestLevel = 7
|
|
77
|
-
# 代码块 fence 内的行跳过:其中的 "# 注释" 不是 markdown 标题,
|
|
78
|
-
# 否则长回复里的代码注释会被误当成标题只念注释
|
|
79
|
-
$inCodeBlock = $false
|
|
80
|
-
foreach ($line in ($text -split "`n")) {
|
|
81
|
-
if ($line -match '^\s*```') { $inCodeBlock = -not $inCodeBlock; continue }
|
|
82
|
-
if ($inCodeBlock) { continue }
|
|
83
|
-
if ($line -match '^\s*#{1,6}\s+') {
|
|
84
|
-
$level = ([regex]::Match($line, '^(\s*)(#+)')).Groups[2].Value.Length
|
|
85
|
-
if ($level -lt $bestLevel) {
|
|
86
|
-
$bestLevel = $level
|
|
87
|
-
$candidate = $line -replace '^\s*#+\s*', ''
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
if ($candidate) {
|
|
92
|
-
# 有标题:只念最大的标题("长回复只报标题")
|
|
93
|
-
$text = $candidate
|
|
94
|
-
} else {
|
|
95
|
-
# 没有标题:**不能只念第一个非空行** —— 那会念出"……官方文档写明:"这类
|
|
96
|
-
# 断头句然后静默停住,听感上就是"从第二行开始不念了"(1.8.0 修复)。
|
|
97
|
-
# 改为取开头 MaxChars 长度的窗口,并在窗口内最后一个句末标点处收尾。
|
|
98
|
-
# 中英双语判据:
|
|
99
|
-
# * 全角 。!?; 与省略号 … 无条件算句末(中文标点不含歧义);
|
|
100
|
-
# * 半角 .!?; 只在后面跟空白、右引号/右括号时才算,否则 "0.1.2"、
|
|
101
|
-
# "file.txt"、"e.g." 里的小数点/扩展名会被当成句子结尾;
|
|
102
|
-
# * 半角标点不认"到窗口结尾"本身就结束:窗口末尾若是小数点,认了等于没
|
|
103
|
-
# 修剪。但会多读一位来判断(见下),所以"句号+空格"在窗口边缘照样成立。
|
|
104
|
-
# 收尾后若不足半个窗口,就保留整个窗口:一整句超长文本不该被砍成一个词。
|
|
105
|
-
#
|
|
106
|
-
# 标点类一律用 [char] 码位拼出来,让源码里**不出现非 ASCII 代码字面量**:
|
|
107
|
-
# 1) PowerShell 把 ’ ” ‘ “ 也当字符串引号,直接写进单引号串会提前截断
|
|
108
|
-
# (报 Missing ')' in method call);
|
|
109
|
-
# 2) 更要紧的是——脚本一旦丢掉 UTF-8 BOM,Windows PowerShell 5.1 会按
|
|
110
|
-
# ANSI 代码页解码,代码里的中文标点会变乱码,句末判定**静默失效**。
|
|
111
|
-
# 代码保持纯 ASCII 后,丢 BOM 只会让中文注释变乱码,不影响行为。
|
|
112
|
-
# 。 ! ? ; … = 0x3002 0xFF01 0xFF1F 0xFF1B 0x2026
|
|
113
|
-
# ) 】 」 』 = 0xFF09 0x3011 0x300D 0x300F
|
|
114
|
-
$fullWidthEnders = [string]([char]0x3002) + [char]0xFF01 + [char]0xFF1F + [char]0xFF1B + [char]0x2026
|
|
115
|
-
$closers = [string]([char]0x22) + [char]0x201D + [char]0x2019 + [char]0xFF09 + [char]0x3011 + [char]0x300D + [char]0x300F + [char]0x29 + '\' + [char]0x5D + [char]0x7D
|
|
116
|
-
$sentenceEndPattern = '(?:[' + $fullWidthEnders + ']|[.!?;](?=[\s' + $closers + ']))'
|
|
117
|
-
$window = $text.Substring(0, [Math]::Min($text.Length, $MaxChars))
|
|
118
|
-
# 多看一个字符再判定,但只在窗口内收尾:句号落在窗口最后一位时,它后面那个
|
|
119
|
-
# 空格在窗口之外,多看一位才能认出"句号+空格"确实是句末;而"句号+数字"
|
|
120
|
-
# (`Version 0.1.` 的第 300 位)依旧被拒。切点永远不超过窗口长度。
|
|
121
|
-
$scan = $text.Substring(0, [Math]::Min($text.Length, $MaxChars + 1))
|
|
122
|
-
# 取**落在窗口内**的最后一个句末标点:scan 多读的那一位会让最后一个匹配可能
|
|
123
|
-
# 落在窗口之外(窗口外正好是个全角句号时),那种匹配必须忽略——但也不能因此
|
|
124
|
-
# 丢掉窗口内更早的合法边界,所以逐个过滤而不是只看最后一个。
|
|
125
|
-
$cut = 0
|
|
126
|
-
foreach ($match in [regex]::Matches($scan, $sentenceEndPattern)) {
|
|
127
|
-
$end = $match.Index + $match.Length
|
|
128
|
-
if ($end -le $window.Length) { $cut = $end }
|
|
129
|
-
}
|
|
130
|
-
if ($cut -ge [Math]::Floor($window.Length / 2)) { $window = $window.Substring(0, $cut) }
|
|
131
|
-
$text = $window
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
# ---------- clean: Markdown -> natural speech text ----------
|
|
136
|
-
if ($cleanMarkdown) {
|
|
137
|
-
$text = [regex]::Replace($text, '```[^\n]*\n?([\s\S]*?)```', {
|
|
138
|
-
param($match)
|
|
139
|
-
$code = $match.Groups[1].Value
|
|
140
|
-
if ($CodeBlocks -eq 'all' -or ($CodeBlocks -eq 'smart' -and $code.Length -le $CodeBlockMaxChars)) { return " $code " }
|
|
141
|
-
return " $CodeBlockReplacementText "
|
|
142
|
-
})
|
|
143
|
-
if ($readInlineCode) { $text = $text -replace '`([^`]*)`', '$1' } else { $text = $text -replace '`[^`]*`', ' ' }
|
|
144
|
-
$text = $text -replace '\[([^\]]*)\]\([^\)]*\)', '$1'
|
|
145
|
-
$text = $text -replace 'https?://\S+', ' '
|
|
146
|
-
$text = $text -replace '(?m)^\s{0,3}(?:#{1,6}\s+|[-*+]\s+|\d+[.)]\s+|>\s?)', ' '
|
|
147
|
-
$text = $text -replace '(\*\*|__|~~)(.*?)\1', '$2'
|
|
148
|
-
$text = $text -replace '[*_~]+', ''
|
|
149
|
-
}
|
|
150
|
-
# Keep all Unicode letters, including Portuguese accents; remove unsafe symbols.
|
|
151
|
-
# 范围写成 \u 转义(纯 ASCII 源码,丢 BOM 也不会乱码):
|
|
152
|
-
# \u4e00-\u9fa5 汉字、\u3000-\u303f 中文标点、\uff00-\uffef 全角、
|
|
153
|
-
# \u2000-\u206f 通用标点(含 … 和 —)、\u0020-\u007e ASCII 可打印。
|
|
154
|
-
$text = [regex]::Replace($text, '[^\p{L}\p{N}\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef\u2000-\u206f\u0020-\u007e]', '')
|
|
155
|
-
$text = $text -replace '\s+', ' '
|
|
156
|
-
$text = $text.Trim()
|
|
157
|
-
|
|
158
|
-
# ---------- final ceiling (also catches over-long heading candidates) ----------
|
|
159
|
-
if (-not $fullReadMode -and $text.Length -gt $MaxChars) { $text = $LongTextMessage }
|
|
160
|
-
|
|
161
|
-
# ---------- dry run: expose the text that would be spoken, silently ----------
|
|
162
|
-
# Maintainer aid: makes the cleaning pipeline and the long-text guard observable
|
|
163
|
-
# without a speaker, so a truncated candidate can be diffed against its source.
|
|
164
|
-
# Written as raw UTF-8 bytes so a redirected capture never depends on the
|
|
165
|
-
# console code page.
|
|
166
|
-
if ($dryRunMode) {
|
|
167
|
-
$bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
|
|
168
|
-
[Console]::OpenStandardOutput().Write($bytes, 0, $bytes.Length)
|
|
169
|
-
exit 0
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
# ---------- speak ----------
|
|
173
|
-
Add-Type -AssemblyName System.Speech
|
|
174
|
-
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
|
|
175
|
-
$synth.Volume = $Volume
|
|
176
|
-
|
|
177
|
-
# prefer a zh natural voice (NaturalVoiceSAPIAdapter-registered), fall back to any zh
|
|
178
|
-
$voices = $synth.GetInstalledVoices()
|
|
179
|
-
$voice = $voices | Where-Object {
|
|
180
|
-
$_.VoiceInfo.Culture.Name -like 'zh*' -and
|
|
181
|
-
($_.VoiceInfo.Name + ' ' + $_.VoiceInfo.Description) -match 'Natural|Online'
|
|
182
|
-
} | Select-Object -First 1
|
|
183
|
-
if (-not $voice) { $voice = $voices | Where-Object { $_.VoiceInfo.Culture.Name -like 'zh*' } | Select-Object -First 1 }
|
|
184
|
-
if ($voice) { $synth.SelectVoice($voice.VoiceInfo.Name) }
|
|
185
|
-
|
|
186
|
-
$synth.Rate = $Rate
|
|
187
|
-
|
|
188
|
-
# ---------- 完整朗读(FullRead,手动重播) ----------
|
|
189
|
-
# Windows SAPI 单次 Speak 有约 375-470 字上限,超长会静默失败;因此按句末
|
|
190
|
-
# 标点切成不超过 450 字的段,逐段朗读(自动播报不经过这里,走上面的守卫)。
|
|
191
|
-
$SPEAK_CHUNK = 400
|
|
192
|
-
if ($fullReadMode -and $text.Length -gt $SPEAK_CHUNK) {
|
|
193
|
-
# 分句标点同样用 \u 转义(纯 ASCII 源码)
|
|
194
|
-
$parts = [regex]::Split($text, '(?<=[\u3002\uff01\uff1f\uff1b.!?;])')
|
|
195
|
-
$chunk = ''
|
|
196
|
-
foreach ($part in $parts) {
|
|
197
|
-
if ($part.Length -eq 0) { continue }
|
|
198
|
-
if ($chunk.Length + $part.Length -gt $SPEAK_CHUNK) {
|
|
199
|
-
if ($chunk) { $synth.Speak($chunk); $chunk = '' }
|
|
200
|
-
# 单段仍超长:硬切
|
|
201
|
-
while ($part.Length -gt $SPEAK_CHUNK) {
|
|
202
|
-
$synth.Speak($part.Substring(0, $SPEAK_CHUNK))
|
|
203
|
-
$part = $part.Substring($SPEAK_CHUNK)
|
|
204
|
-
}
|
|
205
|
-
$chunk = $part
|
|
206
|
-
} else {
|
|
207
|
-
$chunk += $part
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
if ($chunk) { $synth.Speak($chunk) }
|
|
211
|
-
} else {
|
|
212
|
-
$synth.Speak($text)
|
|
213
|
-
}
|
|
214
|
-
exit 0
|
|
1
|
+
# speak.ps1 — Harness-agnostic speech engine (Windows SAPI5 + NaturalVoiceSAPIAdapter)
|
|
2
|
+
# ====================================================================================
|
|
3
|
+
# Reads text (inline or from a UTF-8 file), cleans it for speech synthesis, and reads
|
|
4
|
+
# it aloud through Windows SAPI5, preferring natural voices registered by
|
|
5
|
+
# NaturalVoiceSAPIAdapter (https://github.com/gexgd0419/NaturalVoiceSAPIAdapter).
|
|
6
|
+
#
|
|
7
|
+
# This script knows NOTHING about any harness (DSH, Claude Code, ...). Any process
|
|
8
|
+
# can call it:
|
|
9
|
+
#
|
|
10
|
+
# powershell.exe -NoProfile -ExecutionPolicy Bypass -File speak.ps1 -Text "hello"
|
|
11
|
+
# powershell.exe -NoProfile -ExecutionPolicy Bypass -File speak.ps1 -File C:\tmp\msg.txt
|
|
12
|
+
#
|
|
13
|
+
# It is best-effort by design: it never throws, never blocks the caller for longer
|
|
14
|
+
# than the utterance itself, and exits 0 even if something failed.
|
|
15
|
+
#
|
|
16
|
+
# Design notes (see docs/DESIGN.md for full rationale):
|
|
17
|
+
# * Markdown symbols, URLs and emoji are stripped before speaking — SAPI5 Speak()
|
|
18
|
+
# silently fails (produces no audio, no error) when it hits emoji/surrogates.
|
|
19
|
+
# * NaturalVoiceSAPIAdapter has a per-Speak character ceiling (~375-470 chars);
|
|
20
|
+
# beyond that it silently speaks nothing. Text longer than $MaxChars is replaced
|
|
21
|
+
# with $LongTextMessage instead.
|
|
22
|
+
# * Adapter-registered voices often have plain names ("Microsoft Xiaoxiao") that do
|
|
23
|
+
# not contain the word "Natural", so matching checks Name + Description.
|
|
24
|
+
# ====================================================================================
|
|
25
|
+
|
|
26
|
+
param(
|
|
27
|
+
[string]$Text = '',
|
|
28
|
+
[string]$File = '',
|
|
29
|
+
[int]$Volume = 50,
|
|
30
|
+
[int]$Rate = 1,
|
|
31
|
+
[int]$MaxChars = 300,
|
|
32
|
+
# 本脚本唯一的非 ASCII 代码字面量。丢了 UTF-8 BOM 时 PowerShell 5.1 会按 ANSI
|
|
33
|
+
# 代码页把它解码成乱码——DSH 插件总是显式传 -LongTextMessage,所以只影响手动
|
|
34
|
+
# CLI 调用;脚本逻辑(句末判定 / 字符过滤 / 分句)已全部改成纯 ASCII 源码。
|
|
35
|
+
[string]$LongTextMessage = '本次播报内容较长,请自行阅读。',
|
|
36
|
+
[ValidateSet('message', 'heading')]
|
|
37
|
+
[string]$LongTextMode = 'message',
|
|
38
|
+
# 命令行参数一律是字符串(-File 模式不做类型转换),这里用 [string] 接收,
|
|
39
|
+
# 在脚本内部再转布尔,兼容 '1'/'0'/'true'/'True'/'yes'/'on' 等写法。
|
|
40
|
+
[string]$CleanMarkdownFormatting = 'true',
|
|
41
|
+
[string]$ReadInlineCode = 'true',
|
|
42
|
+
[ValidateSet('all', 'smart', 'replace')]
|
|
43
|
+
[string]$CodeBlocks = 'smart',
|
|
44
|
+
[int]$CodeBlockMaxChars = 300,
|
|
45
|
+
[string]$CodeBlockReplacementText = 'You can see the code in our history.',
|
|
46
|
+
# 手动重播完整朗读:跳过超长文本的 heading/message 截断,分段完整朗读
|
|
47
|
+
[string]$FullRead = '0',
|
|
48
|
+
# 只把「将要朗读的文本」按 UTF-8 写到 stdout、完全不出声(调试清洗与长文
|
|
49
|
+
# 守卫用;正常调用无需传,插件不会传)
|
|
50
|
+
[string]$DryRun = '0'
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
$cleanMarkdown = $CleanMarkdownFormatting -in @('1', 'true', 'yes', 'on')
|
|
54
|
+
$readInlineCode = $ReadInlineCode -in @('1', 'true', 'yes', 'on')
|
|
55
|
+
$dryRunMode = $DryRun -in @('1', 'true', 'yes', 'on')
|
|
56
|
+
# 注意:PowerShell 变量大小写不敏感,内部变量名不能与参数名仅差大小写
|
|
57
|
+
# (曾用 $fullRead 导致自赋值污染参数 $FullRead,使 -not 判断失效)
|
|
58
|
+
$fullReadMode = $FullRead -in @('1', 'true', 'yes', 'on')
|
|
59
|
+
|
|
60
|
+
# ---------- input: pick text source ----------
|
|
61
|
+
if ($File) {
|
|
62
|
+
if (-not (Test-Path $File)) { exit 0 }
|
|
63
|
+
$text = [System.IO.File]::ReadAllText($File, [System.Text.Encoding]::UTF8)
|
|
64
|
+
} else {
|
|
65
|
+
$text = [string]$Text
|
|
66
|
+
}
|
|
67
|
+
if (-not $text -or -not $text.Trim()) { exit 0 }
|
|
68
|
+
|
|
69
|
+
# ---------- length guard: adapter per-Speak ceiling ----------
|
|
70
|
+
# 'message': fixed prompt. 'heading': speak the largest markdown heading instead
|
|
71
|
+
# (fewest '#' wins, tie -> first; with NO heading, speak a coherent opening of
|
|
72
|
+
# the text — see below; the cleaned candidate is still subject to the ceiling
|
|
73
|
+
# below). FullRead 手动重播跳过该守卫(见文件底部"完整朗读"分支)。
|
|
74
|
+
if (-not $fullReadMode -and $MaxChars -gt 0 -and $text.Length -gt $MaxChars -and $LongTextMode -eq 'heading') {
|
|
75
|
+
$candidate = ''
|
|
76
|
+
$bestLevel = 7
|
|
77
|
+
# 代码块 fence 内的行跳过:其中的 "# 注释" 不是 markdown 标题,
|
|
78
|
+
# 否则长回复里的代码注释会被误当成标题只念注释
|
|
79
|
+
$inCodeBlock = $false
|
|
80
|
+
foreach ($line in ($text -split "`n")) {
|
|
81
|
+
if ($line -match '^\s*```') { $inCodeBlock = -not $inCodeBlock; continue }
|
|
82
|
+
if ($inCodeBlock) { continue }
|
|
83
|
+
if ($line -match '^\s*#{1,6}\s+') {
|
|
84
|
+
$level = ([regex]::Match($line, '^(\s*)(#+)')).Groups[2].Value.Length
|
|
85
|
+
if ($level -lt $bestLevel) {
|
|
86
|
+
$bestLevel = $level
|
|
87
|
+
$candidate = $line -replace '^\s*#+\s*', ''
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if ($candidate) {
|
|
92
|
+
# 有标题:只念最大的标题("长回复只报标题")
|
|
93
|
+
$text = $candidate
|
|
94
|
+
} else {
|
|
95
|
+
# 没有标题:**不能只念第一个非空行** —— 那会念出"……官方文档写明:"这类
|
|
96
|
+
# 断头句然后静默停住,听感上就是"从第二行开始不念了"(1.8.0 修复)。
|
|
97
|
+
# 改为取开头 MaxChars 长度的窗口,并在窗口内最后一个句末标点处收尾。
|
|
98
|
+
# 中英双语判据:
|
|
99
|
+
# * 全角 。!?; 与省略号 … 无条件算句末(中文标点不含歧义);
|
|
100
|
+
# * 半角 .!?; 只在后面跟空白、右引号/右括号时才算,否则 "0.1.2"、
|
|
101
|
+
# "file.txt"、"e.g." 里的小数点/扩展名会被当成句子结尾;
|
|
102
|
+
# * 半角标点不认"到窗口结尾"本身就结束:窗口末尾若是小数点,认了等于没
|
|
103
|
+
# 修剪。但会多读一位来判断(见下),所以"句号+空格"在窗口边缘照样成立。
|
|
104
|
+
# 收尾后若不足半个窗口,就保留整个窗口:一整句超长文本不该被砍成一个词。
|
|
105
|
+
#
|
|
106
|
+
# 标点类一律用 [char] 码位拼出来,让源码里**不出现非 ASCII 代码字面量**:
|
|
107
|
+
# 1) PowerShell 把 ’ ” ‘ “ 也当字符串引号,直接写进单引号串会提前截断
|
|
108
|
+
# (报 Missing ')' in method call);
|
|
109
|
+
# 2) 更要紧的是——脚本一旦丢掉 UTF-8 BOM,Windows PowerShell 5.1 会按
|
|
110
|
+
# ANSI 代码页解码,代码里的中文标点会变乱码,句末判定**静默失效**。
|
|
111
|
+
# 代码保持纯 ASCII 后,丢 BOM 只会让中文注释变乱码,不影响行为。
|
|
112
|
+
# 。 ! ? ; … = 0x3002 0xFF01 0xFF1F 0xFF1B 0x2026
|
|
113
|
+
# ) 】 」 』 = 0xFF09 0x3011 0x300D 0x300F
|
|
114
|
+
$fullWidthEnders = [string]([char]0x3002) + [char]0xFF01 + [char]0xFF1F + [char]0xFF1B + [char]0x2026
|
|
115
|
+
$closers = [string]([char]0x22) + [char]0x201D + [char]0x2019 + [char]0xFF09 + [char]0x3011 + [char]0x300D + [char]0x300F + [char]0x29 + '\' + [char]0x5D + [char]0x7D
|
|
116
|
+
$sentenceEndPattern = '(?:[' + $fullWidthEnders + ']|[.!?;](?=[\s' + $closers + ']))'
|
|
117
|
+
$window = $text.Substring(0, [Math]::Min($text.Length, $MaxChars))
|
|
118
|
+
# 多看一个字符再判定,但只在窗口内收尾:句号落在窗口最后一位时,它后面那个
|
|
119
|
+
# 空格在窗口之外,多看一位才能认出"句号+空格"确实是句末;而"句号+数字"
|
|
120
|
+
# (`Version 0.1.` 的第 300 位)依旧被拒。切点永远不超过窗口长度。
|
|
121
|
+
$scan = $text.Substring(0, [Math]::Min($text.Length, $MaxChars + 1))
|
|
122
|
+
# 取**落在窗口内**的最后一个句末标点:scan 多读的那一位会让最后一个匹配可能
|
|
123
|
+
# 落在窗口之外(窗口外正好是个全角句号时),那种匹配必须忽略——但也不能因此
|
|
124
|
+
# 丢掉窗口内更早的合法边界,所以逐个过滤而不是只看最后一个。
|
|
125
|
+
$cut = 0
|
|
126
|
+
foreach ($match in [regex]::Matches($scan, $sentenceEndPattern)) {
|
|
127
|
+
$end = $match.Index + $match.Length
|
|
128
|
+
if ($end -le $window.Length) { $cut = $end }
|
|
129
|
+
}
|
|
130
|
+
if ($cut -ge [Math]::Floor($window.Length / 2)) { $window = $window.Substring(0, $cut) }
|
|
131
|
+
$text = $window
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
# ---------- clean: Markdown -> natural speech text ----------
|
|
136
|
+
if ($cleanMarkdown) {
|
|
137
|
+
$text = [regex]::Replace($text, '```[^\n]*\n?([\s\S]*?)```', {
|
|
138
|
+
param($match)
|
|
139
|
+
$code = $match.Groups[1].Value
|
|
140
|
+
if ($CodeBlocks -eq 'all' -or ($CodeBlocks -eq 'smart' -and $code.Length -le $CodeBlockMaxChars)) { return " $code " }
|
|
141
|
+
return " $CodeBlockReplacementText "
|
|
142
|
+
})
|
|
143
|
+
if ($readInlineCode) { $text = $text -replace '`([^`]*)`', '$1' } else { $text = $text -replace '`[^`]*`', ' ' }
|
|
144
|
+
$text = $text -replace '\[([^\]]*)\]\([^\)]*\)', '$1'
|
|
145
|
+
$text = $text -replace 'https?://\S+', ' '
|
|
146
|
+
$text = $text -replace '(?m)^\s{0,3}(?:#{1,6}\s+|[-*+]\s+|\d+[.)]\s+|>\s?)', ' '
|
|
147
|
+
$text = $text -replace '(\*\*|__|~~)(.*?)\1', '$2'
|
|
148
|
+
$text = $text -replace '[*_~]+', ''
|
|
149
|
+
}
|
|
150
|
+
# Keep all Unicode letters, including Portuguese accents; remove unsafe symbols.
|
|
151
|
+
# 范围写成 \u 转义(纯 ASCII 源码,丢 BOM 也不会乱码):
|
|
152
|
+
# \u4e00-\u9fa5 汉字、\u3000-\u303f 中文标点、\uff00-\uffef 全角、
|
|
153
|
+
# \u2000-\u206f 通用标点(含 … 和 —)、\u0020-\u007e ASCII 可打印。
|
|
154
|
+
$text = [regex]::Replace($text, '[^\p{L}\p{N}\u4e00-\u9fa5\u3000-\u303f\uff00-\uffef\u2000-\u206f\u0020-\u007e]', '')
|
|
155
|
+
$text = $text -replace '\s+', ' '
|
|
156
|
+
$text = $text.Trim()
|
|
157
|
+
|
|
158
|
+
# ---------- final ceiling (also catches over-long heading candidates) ----------
|
|
159
|
+
if (-not $fullReadMode -and $text.Length -gt $MaxChars) { $text = $LongTextMessage }
|
|
160
|
+
|
|
161
|
+
# ---------- dry run: expose the text that would be spoken, silently ----------
|
|
162
|
+
# Maintainer aid: makes the cleaning pipeline and the long-text guard observable
|
|
163
|
+
# without a speaker, so a truncated candidate can be diffed against its source.
|
|
164
|
+
# Written as raw UTF-8 bytes so a redirected capture never depends on the
|
|
165
|
+
# console code page.
|
|
166
|
+
if ($dryRunMode) {
|
|
167
|
+
$bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
|
|
168
|
+
[Console]::OpenStandardOutput().Write($bytes, 0, $bytes.Length)
|
|
169
|
+
exit 0
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
# ---------- speak ----------
|
|
173
|
+
Add-Type -AssemblyName System.Speech
|
|
174
|
+
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
|
|
175
|
+
$synth.Volume = $Volume
|
|
176
|
+
|
|
177
|
+
# prefer a zh natural voice (NaturalVoiceSAPIAdapter-registered), fall back to any zh
|
|
178
|
+
$voices = $synth.GetInstalledVoices()
|
|
179
|
+
$voice = $voices | Where-Object {
|
|
180
|
+
$_.VoiceInfo.Culture.Name -like 'zh*' -and
|
|
181
|
+
($_.VoiceInfo.Name + ' ' + $_.VoiceInfo.Description) -match 'Natural|Online'
|
|
182
|
+
} | Select-Object -First 1
|
|
183
|
+
if (-not $voice) { $voice = $voices | Where-Object { $_.VoiceInfo.Culture.Name -like 'zh*' } | Select-Object -First 1 }
|
|
184
|
+
if ($voice) { $synth.SelectVoice($voice.VoiceInfo.Name) }
|
|
185
|
+
|
|
186
|
+
$synth.Rate = $Rate
|
|
187
|
+
|
|
188
|
+
# ---------- 完整朗读(FullRead,手动重播) ----------
|
|
189
|
+
# Windows SAPI 单次 Speak 有约 375-470 字上限,超长会静默失败;因此按句末
|
|
190
|
+
# 标点切成不超过 450 字的段,逐段朗读(自动播报不经过这里,走上面的守卫)。
|
|
191
|
+
$SPEAK_CHUNK = 400
|
|
192
|
+
if ($fullReadMode -and $text.Length -gt $SPEAK_CHUNK) {
|
|
193
|
+
# 分句标点同样用 \u 转义(纯 ASCII 源码)
|
|
194
|
+
$parts = [regex]::Split($text, '(?<=[\u3002\uff01\uff1f\uff1b.!?;])')
|
|
195
|
+
$chunk = ''
|
|
196
|
+
foreach ($part in $parts) {
|
|
197
|
+
if ($part.Length -eq 0) { continue }
|
|
198
|
+
if ($chunk.Length + $part.Length -gt $SPEAK_CHUNK) {
|
|
199
|
+
if ($chunk) { $synth.Speak($chunk); $chunk = '' }
|
|
200
|
+
# 单段仍超长:硬切
|
|
201
|
+
while ($part.Length -gt $SPEAK_CHUNK) {
|
|
202
|
+
$synth.Speak($part.Substring(0, $SPEAK_CHUNK))
|
|
203
|
+
$part = $part.Substring($SPEAK_CHUNK)
|
|
204
|
+
}
|
|
205
|
+
$chunk = $part
|
|
206
|
+
} else {
|
|
207
|
+
$chunk += $part
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if ($chunk) { $synth.Speak($chunk) }
|
|
211
|
+
} else {
|
|
212
|
+
$synth.Speak($text)
|
|
213
|
+
}
|
|
214
|
+
exit 0
|
package/engine/speak.sh
CHANGED
|
File without changes
|
package/engine/speech-prompt.ps1
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
# speech-prompt.ps1 — Short prompt announcement (synchronous, blocking)
|
|
2
|
-
# Use when a harness/agent needs the user's attention (a question, an approval
|
|
3
|
-
# request). Reads the text through engine/speak.ps1 and waits until it finishes,
|
|
4
|
-
# so the caller knows the announcement was actually spoken.
|
|
5
|
-
#
|
|
6
|
-
# powershell.exe -NoProfile -ExecutionPolicy Bypass -File speech-prompt.ps1 -Text "请做出选择"
|
|
7
|
-
|
|
8
|
-
# 唯一的非 ASCII 代码字面量:丢 UTF-8 BOM 时 PowerShell 5.1 会按 ANSI 代码页把它
|
|
9
|
-
# 解码成乱码——调用方通常显式传 -Text,所以只影响不带参数的调用。
|
|
10
|
-
param([string]$Text = '请做出选择')
|
|
11
|
-
|
|
12
|
-
if (-not $Text) { exit 0 }
|
|
13
|
-
|
|
14
|
-
$speak = Join-Path $PSScriptRoot 'speak.ps1'
|
|
15
|
-
if (-not (Test-Path $speak)) { exit 0 }
|
|
16
|
-
|
|
17
|
-
$tmp = Join-Path $env:TEMP ('speech-prompt-' + [guid]::NewGuid().ToString('N') + '.txt')
|
|
18
|
-
try {
|
|
19
|
-
[System.IO.File]::WriteAllText($tmp, $Text, [System.Text.UTF8Encoding]::new($false))
|
|
20
|
-
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $speak -File $tmp
|
|
21
|
-
exit $LASTEXITCODE
|
|
22
|
-
} finally {
|
|
23
|
-
if (Test-Path $tmp) { Remove-Item $tmp -Force -ErrorAction SilentlyContinue }
|
|
24
|
-
}
|
|
1
|
+
# speech-prompt.ps1 — Short prompt announcement (synchronous, blocking)
|
|
2
|
+
# Use when a harness/agent needs the user's attention (a question, an approval
|
|
3
|
+
# request). Reads the text through engine/speak.ps1 and waits until it finishes,
|
|
4
|
+
# so the caller knows the announcement was actually spoken.
|
|
5
|
+
#
|
|
6
|
+
# powershell.exe -NoProfile -ExecutionPolicy Bypass -File speech-prompt.ps1 -Text "请做出选择"
|
|
7
|
+
|
|
8
|
+
# 唯一的非 ASCII 代码字面量:丢 UTF-8 BOM 时 PowerShell 5.1 会按 ANSI 代码页把它
|
|
9
|
+
# 解码成乱码——调用方通常显式传 -Text,所以只影响不带参数的调用。
|
|
10
|
+
param([string]$Text = '请做出选择')
|
|
11
|
+
|
|
12
|
+
if (-not $Text) { exit 0 }
|
|
13
|
+
|
|
14
|
+
$speak = Join-Path $PSScriptRoot 'speak.ps1'
|
|
15
|
+
if (-not (Test-Path $speak)) { exit 0 }
|
|
16
|
+
|
|
17
|
+
$tmp = Join-Path $env:TEMP ('speech-prompt-' + [guid]::NewGuid().ToString('N') + '.txt')
|
|
18
|
+
try {
|
|
19
|
+
[System.IO.File]::WriteAllText($tmp, $Text, [System.Text.UTF8Encoding]::new($false))
|
|
20
|
+
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $speak -File $tmp
|
|
21
|
+
exit $LASTEXITCODE
|
|
22
|
+
} finally {
|
|
23
|
+
if (Test-Path $tmp) { Remove-Item $tmp -Force -ErrorAction SilentlyContinue }
|
|
24
|
+
}
|
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
# speech-summary.ps1 — Reply summary announcement (synchronous, blocking)
|
|
2
|
-
# For harnesses with no "reply finished" event (e.g. DSH has no Stop hook): the
|
|
3
|
-
# agent calls this at the end of its final reply.
|
|
4
|
-
#
|
|
5
|
-
# powershell.exe -NoProfile -ExecutionPolicy Bypass -File speech-summary.ps1 -Text "总结文本"
|
|
6
|
-
#
|
|
7
|
-
# NOTE: keep this SYNCHRONOUS. An earlier version spawned the inner powershell
|
|
8
|
-
# asynchronously with Start-Process; DSH's sandbox blocks nested sub-process
|
|
9
|
-
# spawning, so it silently produced no audio. The synchronous call chain
|
|
10
|
-
# (summary -> speak.ps1) is the reliable path (cost: caller waits for the
|
|
11
|
-
# utterance to finish).
|
|
12
|
-
|
|
13
|
-
param([string]$Text = '')
|
|
14
|
-
|
|
15
|
-
if (-not $Text) { exit 0 }
|
|
16
|
-
|
|
17
|
-
$speak = Join-Path $PSScriptRoot 'speak.ps1'
|
|
18
|
-
if (-not (Test-Path $speak)) { exit 0 }
|
|
19
|
-
|
|
20
|
-
$tmp = Join-Path $env:TEMP ('speech-summary-' + [guid]::NewGuid().ToString('N') + '.txt')
|
|
21
|
-
try {
|
|
22
|
-
[System.IO.File]::WriteAllText($tmp, $Text, [System.Text.UTF8Encoding]::new($false))
|
|
23
|
-
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $speak -File $tmp
|
|
24
|
-
exit $LASTEXITCODE
|
|
25
|
-
} finally {
|
|
26
|
-
if (Test-Path $tmp) { Remove-Item $tmp -Force -ErrorAction SilentlyContinue }
|
|
27
|
-
}
|
|
1
|
+
# speech-summary.ps1 — Reply summary announcement (synchronous, blocking)
|
|
2
|
+
# For harnesses with no "reply finished" event (e.g. DSH has no Stop hook): the
|
|
3
|
+
# agent calls this at the end of its final reply.
|
|
4
|
+
#
|
|
5
|
+
# powershell.exe -NoProfile -ExecutionPolicy Bypass -File speech-summary.ps1 -Text "总结文本"
|
|
6
|
+
#
|
|
7
|
+
# NOTE: keep this SYNCHRONOUS. An earlier version spawned the inner powershell
|
|
8
|
+
# asynchronously with Start-Process; DSH's sandbox blocks nested sub-process
|
|
9
|
+
# spawning, so it silently produced no audio. The synchronous call chain
|
|
10
|
+
# (summary -> speak.ps1) is the reliable path (cost: caller waits for the
|
|
11
|
+
# utterance to finish).
|
|
12
|
+
|
|
13
|
+
param([string]$Text = '')
|
|
14
|
+
|
|
15
|
+
if (-not $Text) { exit 0 }
|
|
16
|
+
|
|
17
|
+
$speak = Join-Path $PSScriptRoot 'speak.ps1'
|
|
18
|
+
if (-not (Test-Path $speak)) { exit 0 }
|
|
19
|
+
|
|
20
|
+
$tmp = Join-Path $env:TEMP ('speech-summary-' + [guid]::NewGuid().ToString('N') + '.txt')
|
|
21
|
+
try {
|
|
22
|
+
[System.IO.File]::WriteAllText($tmp, $Text, [System.Text.UTF8Encoding]::new($false))
|
|
23
|
+
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $speak -File $tmp
|
|
24
|
+
exit $LASTEXITCODE
|
|
25
|
+
} finally {
|
|
26
|
+
if (Test-Path $tmp) { Remove-Item $tmp -Force -ErrorAction SilentlyContinue }
|
|
27
|
+
}
|