dsh-notify-say 0.4.21 → 0.4.25

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.
Files changed (54) hide show
  1. package/README.md +216 -176
  2. package/docs/audio-audit/README.md +164 -0
  3. package/docs/audio-audit/fetch-snapshots.ps1 +76 -0
  4. package/docs/audio-audit/snapshots/ameharu-hau.txt +89 -0
  5. package/docs/audio-audit/snapshots/goki-nuigurumi.txt +73 -0
  6. package/docs/audio-audit/snapshots/kasukabe-tsumugi.txt +197 -0
  7. package/docs/audio-audit/snapshots/manbetsu-hanamaru.txt +27 -0
  8. package/docs/audio-audit/snapshots/meimei-meria.txt +84 -0
  9. package/docs/audio-audit/snapshots/mochiko.txt +27 -0
  10. package/docs/audio-audit/snapshots/namiine-ritsu.txt +76 -0
  11. package/docs/audio-audit/snapshots/shirakami-kotaro.txt +158 -0
  12. package/docs/audio-audit/snapshots/sss-anonymous-common.txt +142 -0
  13. package/docs/audio-audit/snapshots/sss-zundamon.txt +142 -0
  14. package/docs/audio-audit/snapshots/voicevox-software.txt +41 -0
  15. package/docs/audio-audit/snapshots/voicevox-vvm-terms.txt +385 -0
  16. package/docs/audio-audit/snapshots/yurei-chan.txt +112 -0
  17. package/docs//345/276/205/345/201/232/346/270/205/345/215/225.md +218 -0
  18. package/docs//350/257/255/351/237/263/345/214/205/345/210/206/345/217/221/346/226/271/346/241/210-/345/276/205/345/256/241/351/230/205.md +378 -0
  19. package/lib/client.js +50 -8
  20. package/lib/config-route.js +251 -209
  21. package/lib/http-guard.js +222 -0
  22. package/lib/index.js +1822 -1768
  23. package/lib/session-bridge.js +138 -117
  24. package/package.json +11 -2
  25. package/test/audio-source.test.mjs +184 -0
  26. package/test/audio-wiring.test.mjs +287 -0
  27. package/test/client-bundle.test.mjs +671 -0
  28. package/test/client-contract.test.mjs +118 -0
  29. package/test/config-coverage.test.mjs +109 -0
  30. package/test/config-route.test.mjs +212 -0
  31. package/test/config-schema.test.mjs +147 -0
  32. package/test/config-store.test.mjs +256 -0
  33. package/test/dev-reload.test.mjs +132 -0
  34. package/test/effective-for-client.test.mjs +142 -0
  35. package/test/fail-route.test.mjs +128 -0
  36. package/test/fail-voice.test.mjs +193 -0
  37. package/test/http-guard.test.mjs +143 -0
  38. package/test/http-integration.test.mjs +284 -0
  39. package/test/idle-gate.test.mjs +167 -0
  40. package/test/preview.test.mjs +92 -0
  41. package/test/real-cordis.test.mjs +204 -0
  42. package/test/release-voice-guard.test.mjs +185 -0
  43. package/test/reload-live.test.mjs +261 -0
  44. package/test/reload-verification.test.mjs +218 -0
  45. package/test/route-loader-register.mjs +22 -0
  46. package/test/scalar-guard.test.mjs +125 -0
  47. package/test/session-jump.test.mjs +266 -0
  48. package/test/stub-child-process.mjs +43 -0
  49. package/test/tts-lines.test.mjs +112 -0
  50. package/test/version-consistency.test.mjs +98 -0
  51. package/test/voice-availability.test.mjs +90 -0
  52. package/test/wav-gain.test.mjs +138 -0
  53. package/test/wiring.test.mjs +287 -0
  54. package/tools/notify-config.ps1 +472 -471
@@ -1,471 +1,472 @@
1
- <#
2
- .SYNOPSIS
3
- dsh-notify-say 配置命令行(v0.4.0 的「第 4 层」运行时覆盖)。
4
-
5
- .DESCRIPTION
6
- ## 为什么有它
7
-
8
- v0.4.0 起设置页 GUI 由官方 Config schema 自动生成(改 `storages` 用户层)。
9
- 但有两类场景 GUI 不方便:
10
-
11
- 1. 远程办公/听不见声音,只想一条命令静音;
12
- 2. 不想为改一个音量去重启宿主。
13
-
14
- 本脚本读写 `$DSH_HOME\storages\dsh-notify-say\config.json`
15
- (**本插件唯一拥有的持久文件**,不碰 cordis.patch.yml、不碰凭据、不碰别的插件)。
16
- 插件每次播报前会重读它,故**改完立即生效,无需重启**。
17
-
18
- ## 优先级提醒
19
-
20
- 生效值 = schema 默认 → cordis.patch.yml → **设置页** → **本文件**
21
- 也就是说本文件优先级最高;改过之后设置页里同一项会被覆盖。
22
- 想交还给设置页:用 `-Clear`。
23
-
24
- ## 安全设计
25
-
26
- - 写入前做白名单校验(未知键丢弃),不会污染文件;
27
- - 文件损坏时**拒绝覆盖**并提示,保留原文件供人工救;
28
- - 原子写(先 .tmp 后 rename),不留半截文件;
29
- - 只写本插件自己的目录。
30
-
31
- .PARAMETER Show
32
- 显示当前生效配置(合并了 patch / 设置页 / 本文件的最终值)与来源。
33
-
34
- .PARAMETER Voice
35
- 语音开关:on / off。
36
-
37
- .PARAMETER Volume
38
- 音量 0-100。
39
-
40
- .PARAMETER VoiceMode
41
- 音色模式:fixed(固定) / random(每次随机)。
42
-
43
- .PARAMETER VoiceName
44
- 固定音色 key(如 zundamon-normal)。可用 -ListVoices 查看有哪些。
45
-
46
- .PARAMETER Enable
47
- 打开某类提醒。可多值,逗号或空格分隔:
48
- complete, error, blocked, aborted, approval, question, subagents, idleGate
49
-
50
- .PARAMETER Disable
51
- 关闭某类提醒,取值同上。
52
-
53
- .PARAMETER Clear
54
- 删除本插件的 config.json,把控制权交还「设置页 / cordis.patch.yml」。
55
-
56
- .PARAMETER ListVoices
57
- 列出语句池里可用的音色 key 及各池覆盖情况。
58
-
59
- .PARAMETER DryRun
60
- 只显示将要写入什么,不落盘。
61
-
62
- .EXAMPLE
63
- .\notify-config.ps1 -Show
64
-
65
- .EXAMPLE
66
- # 远程办公,先静音(Toast 仍会弹)
67
- .\notify-config.ps1 -Voice off
68
-
69
- .EXAMPLE
70
- # 音量调到 40,音色改成随机
71
- .\notify-config.ps1 -Volume 40 -VoiceMode random
72
-
73
- .EXAMPLE
74
- # 关掉「中止」类提醒,打开子代理提醒
75
- .\notify-config.ps1 -Disable aborted -Enable subagents
76
-
77
- .EXAMPLE
78
- # 交还控制权(回到设置页/patch 的值)
79
- .\notify-config.ps1 -Clear
80
- #>
81
- [CmdletBinding(DefaultParameterSetName = 'Set')]
82
- param(
83
- [switch]$Show,
84
- [ValidateSet('on', 'off')]
85
- [string]$Voice,
86
- [ValidateRange(0, 100)]
87
- [int]$Volume,
88
- [ValidateSet('fixed', 'random')]
89
- [string]$VoiceMode,
90
- [string]$VoiceName,
91
- [string[]]$Enable,
92
- [string[]]$Disable,
93
- [switch]$Clear,
94
- [switch]$ListVoices,
95
- # ── 语音包管理(按需下载)────────────────────────────────────────────────
96
- # 完整语音包 47.93 MB;核心包只带一个音色。没装语音包时自动回退系统提示音。
97
- [switch]$ListPacks,
98
- [string]$PurgeVoice,
99
- [switch]$InstallHint,
100
- [switch]$DryRun
101
- )
102
-
103
- $ErrorActionPreference = 'Stop'
104
-
105
- # ── 路径解析 ────────────────────────────────────────────────────────────────
106
- # 本脚本位于 <plugin>/tools/,插件根 = 上一级。
107
- # ⚠️ 不能用 $MyInvocation.MyCommand.Path —— 经 `powershell -File` 启动时它可能为空,
108
- # 导致 $pluginRoot 变成 $null、进而 Join-Path 拼出错误路径(实测踩到)。
109
- # 用 $PSScriptRoot(-File 与点源两种方式都可靠)+ $MyInvocation.MyCommand.Definition 兜底。
110
- $scriptPath = $PSScriptRoot
111
- if (-not $scriptPath) {
112
- $scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
113
- }
114
- if (-not $scriptPath) {
115
- Write-Host " 无法确定脚本所在目录,退出。" -ForegroundColor Red
116
- exit 1
117
- }
118
- $pluginRoot = Split-Path -Parent $scriptPath
119
- $notifyPs1 = Join-Path $pluginRoot 'dsh-notify.ps1'
120
-
121
- function Get-DshHome {
122
- $h = $env:DSH_HOME
123
- if (-not $h) { $h = 'D:\Program Files (x86)\DSH' } # 本机默认,仅作兜底
124
- return $h.TrimEnd('\', '/')
125
- }
126
-
127
- $dshHome = Get-DshHome
128
- $configDir = Join-Path $dshHome 'storages\dsh-notify-say'
129
- $configPath = Join-Path $configDir 'config.json'
130
-
131
- # ── 提醒类别 → 配置键映射 ───────────────────────────────────────────────────
132
- $KindMap = [ordered]@{
133
- complete = 'notifyOnComplete'
134
- error = 'notifyOnError'
135
- blocked = 'notifyOnBlocked'
136
- aborted = 'notifyOnAborted'
137
- approval = 'notifyOnApproval'
138
- question = 'notifyOnQuestion'
139
- subagents = 'subagents'
140
- idlegate = 'idleGate'
141
- }
142
-
143
- $KindDesc = @{
144
- notifyOnComplete = '回合正常完成'
145
- notifyOnError = '回合出错(失败语音)'
146
- notifyOnBlocked = '回合被拦截(蜂鸣)'
147
- notifyOnAborted = '中止/超长/中断'
148
- notifyOnApproval = '待你批准'
149
- notifyOnQuestion = '向你提问'
150
- subagents = '子代理会话也提醒'
151
- idleGate = '空闲门(有在飞工作先不播完成)'
152
- }
153
-
154
- # ── 读现有配置(损坏则拒绝写,保留原文件)───────────────────────────────────
155
- function Read-ConfigDoc {
156
- if (-not (Test-Path -LiteralPath $configPath)) {
157
- return @{ ok = $true; values = @{}; exists = $false }
158
- }
159
- try {
160
- $raw = Get-Content -LiteralPath $configPath -Raw -Encoding UTF8
161
- } catch {
162
- return @{ ok = $false; reason = "读取失败: $($_.Exception.Message)"; values = @{}; exists = $true }
163
- }
164
- if (-not $raw -or -not $raw.Trim()) { return @{ ok = $true; values = @{}; exists = $true } }
165
- try {
166
- $doc = $raw | ConvertFrom-Json
167
- } catch {
168
- return @{ ok = $false; reason = '文件不是合法 JSON'; values = @{}; exists = $true }
169
- }
170
- if ($null -eq $doc -or $doc.version -ne 1) {
171
- return @{ ok = $false; reason = "版本不匹配(期望 1,实际 $($doc.version))"; values = @{}; exists = $true }
172
- }
173
- $vals = @{}
174
- if ($doc.values) {
175
- foreach ($p in $doc.values.PSObject.Properties) { $vals[$p.Name] = $p.Value }
176
- }
177
- return @{ ok = $true; values = $vals; exists = $true }
178
- }
179
-
180
- function Write-ConfigDoc([hashtable]$values) {
181
- if ($DryRun) {
182
- Write-Host " [DryRun] 将写入 $configPath :" -ForegroundColor Yellow
183
- $values.GetEnumerator() | Sort-Object Name | ForEach-Object { Write-Host (" {0} = {1}" -f $_.Key, $_.Value) }
184
- return $true
185
- }
186
- New-Item -ItemType Directory -Force -Path $configDir | Out-Null
187
- $doc = [ordered]@{ version = 1; values = [ordered]@{} }
188
- foreach ($k in ($values.Keys | Sort-Object)) { $doc.values[$k] = $values[$k] }
189
- $json = $doc | ConvertTo-Json -Depth 6
190
- $tmp = "$configPath.tmp"
191
- try {
192
- [System.IO.File]::WriteAllText($tmp, $json + "`n", (New-Object System.Text.UTF8Encoding($false)))
193
- Move-Item -LiteralPath $tmp -Destination $configPath -Force
194
- } catch {
195
- if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue }
196
- Write-Host " 写入失败: $($_.Exception.Message)" -ForegroundColor Red
197
- return $false
198
- }
199
- return $true
200
- }
201
-
202
- # ── -Show:显示生效值与来源 ─────────────────────────────────────────────────
203
- function Show-Effective {
204
- Write-Host "=== dsh-notify-say 配置 ===" -ForegroundColor Cyan
205
- Write-Host (" DSH_HOME : {0}" -f $dshHome)
206
- Write-Host (" CLI 覆盖层 : {0}" -f $configPath)
207
-
208
- $cur = Read-ConfigDoc
209
- if (-not $cur.ok) {
210
- Write-Host (" ⚠️ 覆盖层损坏:{0}" -f $cur.reason) -ForegroundColor Yellow
211
- Write-Host " 插件会忽略它并回退到上层配置;删掉该文件即可恢复。" -ForegroundColor Yellow
212
- } elseif (-not $cur.exists) {
213
- Write-Host " 状态: 不存在(全部走 设置页 / cordis.patch.yml)" -ForegroundColor DarkGray
214
- } else {
215
- Write-Host (" 状态: 有 {0} 项覆盖" -f $cur.values.Count) -ForegroundColor DarkGray
216
- }
217
-
218
- Write-Host ""
219
- Write-Host "--- 覆盖层里的值(本文件优先级最高)---" -ForegroundColor Cyan
220
- if ($cur.ok -and $cur.values.Count -gt 0) {
221
- $cur.values.GetEnumerator() | Sort-Object Name | ForEach-Object {
222
- $desc = if ($KindDesc.ContainsKey($_.Key)) { $KindDesc[$_.Key] } else { '' }
223
- Write-Host (" {0,-20} = {1,-8} {2}" -f $_.Key, $_.Value, $desc)
224
- }
225
- } else {
226
- Write-Host " (无)" -ForegroundColor DarkGray
227
- }
228
-
229
- # 从 patch 读基线(只读,不解释优先级,仅供对照)
230
- $patch = Join-Path $pluginRoot 'cordis.patch.yml'
231
- if (Test-Path -LiteralPath $patch) {
232
- Write-Host ""
233
- Write-Host "--- cordis.patch.yml 里的基线(会被上面覆盖)---" -ForegroundColor Cyan
234
- Get-Content -LiteralPath $patch -Encoding UTF8 |
235
- Where-Object { $_ -match '^\s+\w+:' } |
236
- ForEach-Object { Write-Host (" " + $_.Trim()) }
237
- }
238
- Write-Host ""
239
- Write-Host "提示:设置页的值介于两者之间,此处无法直接读取(需宿主接口)。" -ForegroundColor DarkGray
240
- }
241
-
242
- # ── -ListVoices:列音色及各池覆盖 ───────────────────────────────────────────
243
- function Show-Voices {
244
- $vl = Join-Path $pluginRoot 'sounds\voice-lines'
245
- if (-not (Test-Path -LiteralPath $vl)) { Write-Host " 找不到语句池目录" -ForegroundColor Red; return }
246
-
247
- # done 池根目录的音色 key(去掉 ~N 变体)
248
- $doneKeys = @(Get-ChildItem -LiteralPath $vl -File -Filter '*.wav' -ErrorAction SilentlyContinue |
249
- ForEach-Object { $_.BaseName -replace '~\d+$', '' } | Sort-Object -Unique)
250
- foreach ($pool in @('question', 'approval', 'fail')) {
251
- $d = Join-Path $vl $pool
252
- if (Test-Path -LiteralPath $d) {
253
- Set-Variable -Name "keys_$pool" -Value @(Get-ChildItem -LiteralPath $d -File -Filter '*.wav' -ErrorAction SilentlyContinue |
254
- ForEach-Object { $_.BaseName -replace '~\d+$', '' } | Sort-Object -Unique)
255
- }
256
- }
257
-
258
- Write-Host "=== 可用音色(共 $($doneKeys.Count) 个 key)===" -ForegroundColor Cyan
259
- Write-Host " 标记含义:完成=有完成语音 提问/审批/失败=对应池是否覆盖" -ForegroundColor DarkGray
260
- Write-Host ""
261
- Write-Host (" {0,-26} {1,-6} {2,-6} {3,-6} {4}" -f '音色 key', '完成', '提问', '审批', '失败')
262
- Write-Host (" " + ("-" * 62))
263
- foreach ($k in $doneKeys) {
264
- $q = if ((Get-Variable -Name 'keys_question' -ValueOnly -ErrorAction SilentlyContinue) -contains $k) { '✓' } else { '-' }
265
- $a = if ((Get-Variable -Name 'keys_approval' -ValueOnly -ErrorAction SilentlyContinue) -contains $k) { '✓' } else { '-' }
266
- $f = if ((Get-Variable -Name 'keys_fail' -ValueOnly -ErrorAction SilentlyContinue) -contains $k) { '✓' } else { '-' }
267
- $warn = if ($f -eq '-') { ' ← 失败时无此音色台词' } else { '' }
268
- Write-Host (" {0,-26} {1,-6} {2,-6} {3,-6} {4}{5}" -f $k, '✓', $q, $a, $f, $warn)
269
- }
270
- Write-Host ""
271
- Write-Host "说明:某池未覆盖该音色时,v0.3.1+ 会在**同池内换一个音色**(不会哑掉,也不会越池播错语义)。" -ForegroundColor DarkGray
272
- }
273
-
274
- # ── 语音包管理:列出本地已装包 ──────────────────────────────────────────────
275
- function Show-Packs {
276
- $vl = Join-Path $pluginRoot 'sounds\voice-lines'
277
- Write-Host "=== 已装语音包 ===" -ForegroundColor Cyan
278
-
279
- if (-not (Test-Path -LiteralPath $vl)) {
280
- Write-Host " 没有任何语音包。" -ForegroundColor DarkGray
281
- Write-Host " → 通知会使用系统提示音 + Toast(这是正常状态,不是错误)。" -ForegroundColor DarkGray
282
- Write-Host " → 用 -ListVoices 查看目录里有哪些音色 key 可选;用 -PurgeVoice <key> 删除。" -ForegroundColor DarkGray
283
- return
284
- }
285
-
286
- # 按音色 key 汇总(去 ~N 变体)
287
- $acc = @{}
288
- foreach ($pool in @('', 'question', 'approval', 'fail')) {
289
- $d = if ($pool) { Join-Path $vl $pool } else { $vl }
290
- if (-not (Test-Path -LiteralPath $d)) { continue }
291
- Get-ChildItem -LiteralPath $d -File -Filter '*.wav' -ErrorAction SilentlyContinue | ForEach-Object {
292
- $k = $_.BaseName -replace '~\d+$', ''
293
- if (-not $acc.ContainsKey($k)) { $acc[$k] = @{ pools = @(); bytes = 0; files = 0 } }
294
- $pname = if ($pool) { $pool } else { 'done' }
295
- if ($acc[$k].pools -notcontains $pname) { $acc[$k].pools += $pname }
296
- $acc[$k].bytes += $_.Length
297
- $acc[$k].files++
298
- }
299
- }
300
-
301
- if ($acc.Count -eq 0) {
302
- Write-Host " 目录存在但没有 wav 文件。" -ForegroundColor DarkGray
303
- return
304
- }
305
-
306
- $totalMB = 0
307
- Write-Host (" {0,-26} {1,-34} {2}" -f '音色 key', '覆盖的语句类', '体积')
308
- Write-Host (" " + ("-" * 74))
309
- foreach ($k in ($acc.Keys | Sort-Object)) {
310
- $v = $acc[$k]
311
- $mb = [math]::Round($v.bytes / 1MB, 2)
312
- $totalMB += $mb
313
- Write-Host (" {0,-26} {1,-34} {2,7:N2} MB" -f $k, ($v.pools -join ','), $mb)
314
- }
315
- Write-Host (" " + ("-" * 74))
316
- Write-Host (" 合计 {0} 个音色 / {1:N2} MB" -f $acc.Count, $totalMB) -ForegroundColor Cyan
317
- Write-Host ""
318
- Write-Host " 说明:只装了 done 的音色,在失败时会**在同池内换音色**或回落提示音(不会播错语义)。" -ForegroundColor DarkGray
319
- Write-Host " 完整语音包约 48 MB;核心包只需 1 个音色。删掉不用的用 -PurgeVoice <key>。" -ForegroundColor DarkGray
320
- }
321
-
322
- # ── 语音包管理:删除某音色 ──────────────────────────────────────────────────
323
- function Remove-VoicePack([string]$key) {
324
- if (-not $key) { Write-Host " 需要 -PurgeVoice <音色key>" -ForegroundColor Red; return }
325
- if ($key -notmatch '^\w[\w-]*$') {
326
- Write-Host " 非法音色 key:$key" -ForegroundColor Red; return
327
- }
328
- $vl = Join-Path $pluginRoot 'sounds\voice-lines'
329
- $targets = @()
330
- foreach ($pool in @('', 'question', 'approval', 'fail')) {
331
- $d = if ($pool) { Join-Path $vl $pool } else { $vl }
332
- if (-not (Test-Path -LiteralPath $d)) { continue }
333
- Get-ChildItem -LiteralPath $d -File -Filter '*.wav' -ErrorAction SilentlyContinue | Where-Object {
334
- ($_.BaseName -replace '~\d+$', '') -eq $key
335
- } | ForEach-Object { $targets += $_ }
336
- }
337
- if ($targets.Count -eq 0) {
338
- Write-Host " 未找到音色 '$key' 的任何 wav。" -ForegroundColor Yellow
339
- return
340
- }
341
- $mb = [math]::Round(($targets | Measure-Object Length -Sum).Sum / 1MB, 2)
342
- Write-Host (" 将删除 {0} 个文件 / {1:N2} MB(音色 {2})" -f $targets.Count, $mb, $key) -ForegroundColor Yellow
343
- if ($DryRun) { Write-Host " [DryRun] 未删除" -ForegroundColor Yellow; return }
344
- $n = 0
345
- foreach ($t in $targets) {
346
- try { Remove-Item -LiteralPath $t.FullName -Force; $n++ } catch { }
347
- }
348
- Write-Host (" 已删除 {0} 个文件。" -f $n) -ForegroundColor Green
349
- if (-not (Test-Path -LiteralPath $vl) -or @(Get-ChildItem -LiteralPath $vl -Recurse -File -Filter '*.wav' -ErrorAction SilentlyContinue).Count -eq 0) {
350
- Write-Host " 已无任何语音包 → 通知将使用系统提示音 + Toast(正常状态)。" -ForegroundColor DarkGray
351
- }
352
- }
353
-
354
- # ── 语音包管理:说明如何获取更多 ────────────────────────────────────────────
355
- function Show-InstallHint {
356
- Write-Host "=== 如何获得更多语音 ===" -ForegroundColor Cyan
357
- Write-Host ""
358
- Write-Host " 核心包只带 1 个日语音色(约 1 MB)。完整 16 音色包约 48 MB,故不随包发布。" -ForegroundColor Gray
359
- Write-Host ""
360
- Write-Host " A) 自己合成(推荐,零分发成本、可自选音色)" -ForegroundColor Green
361
- Write-Host " 1. 安装 VOICEVOX(本地免费 TTS 引擎,约 1 GB)"
362
- Write-Host " 2. 启动引擎(默认 http://127.0.0.1:50021)"
363
- Write-Host " 3. 跑仓库里的合成脚本:"
364
- Write-Host " powershell -File <插件根>\..\..\..\tools\voicevox-build-lines.ps1"
365
- Write-Host ""
366
- Write-Host " B) 从他人镜像下载(如提供)" -ForegroundColor Green
367
- Write-Host " 把 wav 放进 <插件根>\sounds\voice-lines\ 及子目录:"
368
- Write-Host " done -> sounds\voice-lines\<key>~N.wav"
369
- Write-Host " question -> sounds\voice-lines\question\<key>~N.wav"
370
- Write-Host " approval -> sounds\voice-lines\approval\<key>~N.wav"
371
- Write-Host " fail -> sounds\voice-lines\fail\<key>~N.wav"
372
- Write-Host ""
373
- Write-Host " C) 不装任何语音包(也是完全可用的配置)" -ForegroundColor Green
374
- Write-Host " 通知 = Windows Toast + 系统提示音。用 -Voice off 可只留 Toast。" -ForegroundColor DarkGray
375
- Write-Host ""
376
- Write-Host " 装好后用 -ListPacks 查看,用 -VoiceName <key> 选定音色。" -ForegroundColor Gray
377
- }
378
-
379
- # ── 主逻辑 ──────────────────────────────────────────────────────────────────
380
- if ($Show) { Show-Effective; return }
381
- if ($ListVoices) { Show-Voices; return }
382
- if ($ListPacks) { Show-Packs; return }
383
- if ($InstallHint) { Show-InstallHint; return }
384
- if ($PSBoundParameters.ContainsKey('PurgeVoice')) { Remove-VoicePack $PurgeVoice; return }
385
-
386
- if ($Clear) {
387
- if (-not (Test-Path -LiteralPath $configPath)) {
388
- Write-Host " 覆盖层本来就不存在,无需清理。" -ForegroundColor DarkGray
389
- return
390
- }
391
- if ($DryRun) { Write-Host " [DryRun] 将删除 $configPath" -ForegroundColor Yellow; return }
392
- Remove-Item -LiteralPath $configPath -Force
393
- Write-Host " 已删除覆盖层:$configPath" -ForegroundColor Green
394
- Write-Host " 控制权已交还「设置页 / cordis.patch.yml」。" -ForegroundColor Green
395
- return
396
- }
397
-
398
- # 组装变更
399
- $cur = Read-ConfigDoc
400
- if (-not $cur.ok) {
401
- Write-Host " 拒绝写入:配置覆盖层已损坏($($cur.reason))" -ForegroundColor Red
402
- Write-Host " 原文件保留在:$configPath" -ForegroundColor Red
403
- Write-Host " 请先修复或删除它,再重试。" -ForegroundColor Red
404
- exit 1
405
- }
406
-
407
- $vals = @{}
408
- foreach ($k in $cur.values.Keys) { $vals[$k] = $cur.values[$k] }
409
- $changed = @{}
410
-
411
- if ($PSBoundParameters.ContainsKey('Voice')) {
412
- $v = ($Voice -eq 'on')
413
- $vals['voice'] = $v; $changed['voice'] = $v
414
- }
415
- if ($PSBoundParameters.ContainsKey('Volume')) {
416
- $vals['volume'] = $Volume; $changed['volume'] = $Volume
417
- }
418
- if ($PSBoundParameters.ContainsKey('VoiceMode')) {
419
- $vals['voiceMode'] = $VoiceMode; $changed['voiceMode'] = $VoiceMode
420
- }
421
- if ($PSBoundParameters.ContainsKey('VoiceName')) {
422
- if ($VoiceName -notmatch '^\w[\w-]*$') {
423
- Write-Host " 非法音色 key:$VoiceName(只允许字母数字下划线连字符)" -ForegroundColor Red
424
- exit 1
425
- }
426
- $vals['voiceName'] = $VoiceName; $changed['voiceName'] = $VoiceName
427
- }
428
- foreach ($e in @($Enable)) {
429
- if (-not $e) { continue }
430
- foreach ($one in ($e -split '[,\s]+' | Where-Object { $_ })) {
431
- $key = $KindMap[$one.ToLower()]
432
- if (-not $key) {
433
- Write-Host " 未知类别:$one(可用:$($KindMap.Keys -join ', '))" -ForegroundColor Red
434
- exit 1
435
- }
436
- $vals[$key] = $true; $changed[$key] = $true
437
- }
438
- }
439
- foreach ($d in @($Disable)) {
440
- if (-not $d) { continue }
441
- foreach ($one in ($d -split '[,\s]+' | Where-Object { $_ })) {
442
- $key = $KindMap[$one.ToLower()]
443
- if (-not $key) {
444
- Write-Host " 未知类别:$one(可用:$($KindMap.Keys -join ', '))" -ForegroundColor Red
445
- exit 1
446
- }
447
- $vals[$key] = $false; $changed[$key] = $false
448
- }
449
- }
450
-
451
- if ($changed.Count -eq 0) {
452
- Write-Host " 没有指定任何变更。用 -Show 查看当前值,或 -? 看用法。" -ForegroundColor Yellow
453
- return
454
- }
455
-
456
- Write-Host "=== 将要变更 ===" -ForegroundColor Cyan
457
- $changed.GetEnumerator() | Sort-Object Name | ForEach-Object {
458
- $old = if ($cur.values.ContainsKey($_.Key)) { $cur.values[$_.Key] } else { '(未设)' }
459
- $desc = if ($KindDesc.ContainsKey($_.Key)) { $KindDesc[$_.Key] } else { '' }
460
- Write-Host (" {0,-20} {1} -> {2} {3}" -f $_.Key, $old, $_.Value, $desc)
461
- }
462
-
463
- if (-not (Write-ConfigDoc $vals)) { exit 1 }
464
-
465
- if (-not $DryRun) {
466
- Write-Host ""
467
- Write-Host " ✓ 已保存。插件下次播报即生效,**无需重启**。" -ForegroundColor Green
468
- if ($changed.ContainsKey('voice') -and $changed['voice'] -eq $false) {
469
- Write-Host " 语音已关闭;Toast 仍会弹出。" -ForegroundColor DarkGray
470
- }
471
- }
1
+ <#
2
+ .SYNOPSIS
3
+ dsh-notify-say 配置命令行(v0.4.0 的「第 4 层」运行时覆盖)。
4
+
5
+ .DESCRIPTION
6
+ ## 为什么有它
7
+
8
+ v0.4.0 起设置页 GUI 由官方 Config schema 自动生成(改 `storages` 用户层)。
9
+ 但有两类场景 GUI 不方便:
10
+
11
+ 1. 远程办公/听不见声音,只想一条命令静音;
12
+ 2. 不想为改一个音量去重启宿主。
13
+
14
+ 本脚本读写 `$DSH_HOME\storages\dsh-notify-say\config.json`
15
+ (**本插件唯一拥有的持久文件**,不碰 cordis.patch.yml、不碰凭据、不碰别的插件)。
16
+ 插件每次播报前会重读它,故**改完立即生效,无需重启**。
17
+
18
+ ## 优先级提醒
19
+
20
+ 生效值 = schema 默认 → cordis.patch.yml → **设置页** → **本文件**
21
+ 也就是说本文件优先级最高;改过之后设置页里同一项会被覆盖。
22
+ 想交还给设置页:用 `-Clear`。
23
+
24
+ ## 安全设计
25
+
26
+ - 写入前做白名单校验(未知键丢弃),不会污染文件;
27
+ - 文件损坏时**拒绝覆盖**并提示,保留原文件供人工救;
28
+ - 原子写(先 .tmp 后 rename),不留半截文件;
29
+ - 只写本插件自己的目录。
30
+
31
+ .PARAMETER Show
32
+ 显示当前生效配置(合并了 patch / 设置页 / 本文件的最终值)与来源。
33
+
34
+ .PARAMETER Voice
35
+ 语音开关:on / off。
36
+
37
+ .PARAMETER Volume
38
+ 音量 0-100。
39
+
40
+ .PARAMETER VoiceMode
41
+ 音色模式:fixed(固定) / random(每次随机)。
42
+
43
+ .PARAMETER VoiceName
44
+ 固定音色 key(如 zundamon-normal)。可用 -ListVoices 查看有哪些。
45
+
46
+ .PARAMETER Enable
47
+ 打开某类提醒。可多值,逗号或空格分隔:
48
+ complete, error, blocked, aborted, approval, question, subagents, idleGate
49
+
50
+ .PARAMETER Disable
51
+ 关闭某类提醒,取值同上。
52
+
53
+ .PARAMETER Clear
54
+ 删除本插件的 config.json,把控制权交还「设置页 / cordis.patch.yml」。
55
+
56
+ .PARAMETER ListVoices
57
+ 列出语句池里可用的音色 key 及各池覆盖情况。
58
+
59
+ .PARAMETER DryRun
60
+ 只显示将要写入什么,不落盘。
61
+
62
+ .EXAMPLE
63
+ .\notify-config.ps1 -Show
64
+
65
+ .EXAMPLE
66
+ # 远程办公,先静音(Toast 仍会弹)
67
+ .\notify-config.ps1 -Voice off
68
+
69
+ .EXAMPLE
70
+ # 音量调到 40,音色改成随机
71
+ .\notify-config.ps1 -Volume 40 -VoiceMode random
72
+
73
+ .EXAMPLE
74
+ # 关掉「中止」类提醒,打开子代理提醒
75
+ .\notify-config.ps1 -Disable aborted -Enable subagents
76
+
77
+ .EXAMPLE
78
+ # 交还控制权(回到设置页/patch 的值)
79
+ .\notify-config.ps1 -Clear
80
+ #>
81
+ [CmdletBinding(DefaultParameterSetName = 'Set')]
82
+ param(
83
+ [switch]$Show,
84
+ [ValidateSet('on', 'off')]
85
+ [string]$Voice,
86
+ [ValidateRange(0, 100)]
87
+ [int]$Volume,
88
+ [ValidateSet('fixed', 'random')]
89
+ [string]$VoiceMode,
90
+ [string]$VoiceName,
91
+ [string[]]$Enable,
92
+ [string[]]$Disable,
93
+ [switch]$Clear,
94
+ [switch]$ListVoices,
95
+ # ── 语音包管理(按需下载)────────────────────────────────────────────────
96
+ # 完整语音包 47.93 MB;核心包只带一个音色。没装语音包时自动回退系统提示音。
97
+ [switch]$ListPacks,
98
+ [string]$PurgeVoice,
99
+ [switch]$InstallHint,
100
+ [switch]$DryRun
101
+ )
102
+
103
+ $ErrorActionPreference = 'Stop'
104
+
105
+ # ── 路径解析 ────────────────────────────────────────────────────────────────
106
+ # 本脚本位于 <plugin>/tools/,插件根 = 上一级。
107
+ # ⚠️ 不能用 $MyInvocation.MyCommand.Path —— 经 `powershell -File` 启动时它可能为空,
108
+ # 导致 $pluginRoot 变成 $null、进而 Join-Path 拼出错误路径(实测踩到)。
109
+ # 用 $PSScriptRoot(-File 与点源两种方式都可靠)+ $MyInvocation.MyCommand.Definition 兜底。
110
+ $scriptPath = $PSScriptRoot
111
+ if (-not $scriptPath) {
112
+ $scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
113
+ }
114
+ if (-not $scriptPath) {
115
+ Write-Host " 无法确定脚本所在目录,退出。" -ForegroundColor Red
116
+ exit 1
117
+ }
118
+ $pluginRoot = Split-Path -Parent $scriptPath
119
+ $notifyPs1 = Join-Path $pluginRoot 'dsh-notify.ps1'
120
+
121
+ function Get-DshHome {
122
+ $h = $env:DSH_HOME
123
+ if (-not $h) { $h = 'D:\Program Files (x86)\DSH' } # 本机默认,仅作兜底
124
+ return $h.TrimEnd('\', '/')
125
+ }
126
+
127
+ $dshHome = Get-DshHome
128
+ $configDir = Join-Path $dshHome 'storages\dsh-notify-say'
129
+ $configPath = Join-Path $configDir 'config.json'
130
+
131
+ # ── 提醒类别 → 配置键映射 ───────────────────────────────────────────────────
132
+ $KindMap = [ordered]@{
133
+ complete = 'notifyOnComplete'
134
+ error = 'notifyOnError'
135
+ blocked = 'notifyOnBlocked'
136
+ aborted = 'notifyOnAborted'
137
+ approval = 'notifyOnApproval'
138
+ question = 'notifyOnQuestion'
139
+ subagents = 'subagents'
140
+ idlegate = 'idleGate'
141
+ }
142
+
143
+ $KindDesc = @{
144
+ notifyOnComplete = '回合正常完成'
145
+ notifyOnError = '回合出错(失败语音)'
146
+ notifyOnBlocked = '回合被拦截(蜂鸣)'
147
+ notifyOnAborted = '中止/超长/中断'
148
+ notifyOnApproval = '待你批准'
149
+ notifyOnQuestion = '向你提问'
150
+ subagents = '子代理会话也提醒'
151
+ idleGate = '空闲门(有在飞工作先不播完成)'
152
+ }
153
+
154
+ # ── 读现有配置(损坏则拒绝写,保留原文件)───────────────────────────────────
155
+ function Read-ConfigDoc {
156
+ if (-not (Test-Path -LiteralPath $configPath)) {
157
+ return @{ ok = $true; values = @{}; exists = $false }
158
+ }
159
+ try {
160
+ $raw = Get-Content -LiteralPath $configPath -Raw -Encoding UTF8
161
+ } catch {
162
+ return @{ ok = $false; reason = "读取失败: $($_.Exception.Message)"; values = @{}; exists = $true }
163
+ }
164
+ if (-not $raw -or -not $raw.Trim()) { return @{ ok = $true; values = @{}; exists = $true } }
165
+ try {
166
+ $doc = $raw | ConvertFrom-Json
167
+ } catch {
168
+ return @{ ok = $false; reason = '文件不是合法 JSON'; values = @{}; exists = $true }
169
+ }
170
+ if ($null -eq $doc -or $doc.version -ne 1) {
171
+ return @{ ok = $false; reason = "版本不匹配(期望 1,实际 $($doc.version))"; values = @{}; exists = $true }
172
+ }
173
+ $vals = @{}
174
+ if ($doc.values) {
175
+ foreach ($p in $doc.values.PSObject.Properties) { $vals[$p.Name] = $p.Value }
176
+ }
177
+ return @{ ok = $true; values = $vals; exists = $true }
178
+ }
179
+
180
+ function Write-ConfigDoc([hashtable]$values) {
181
+ if ($DryRun) {
182
+ Write-Host " [DryRun] 将写入 $configPath :" -ForegroundColor Yellow
183
+ $values.GetEnumerator() | Sort-Object Name | ForEach-Object { Write-Host (" {0} = {1}" -f $_.Key, $_.Value) }
184
+ return $true
185
+ }
186
+ New-Item -ItemType Directory -Force -Path $configDir | Out-Null
187
+ $doc = [ordered]@{ version = 1; values = [ordered]@{} }
188
+ foreach ($k in ($values.Keys | Sort-Object)) { $doc.values[$k] = $values[$k] }
189
+ $json = $doc | ConvertTo-Json -Depth 6
190
+ $tmp = "$configPath.tmp"
191
+ try {
192
+ [System.IO.File]::WriteAllText($tmp, $json + "`n", (New-Object System.Text.UTF8Encoding($false)))
193
+ Move-Item -LiteralPath $tmp -Destination $configPath -Force
194
+ } catch {
195
+ if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue }
196
+ Write-Host " 写入失败: $($_.Exception.Message)" -ForegroundColor Red
197
+ return $false
198
+ }
199
+ return $true
200
+ }
201
+
202
+ # ── -Show:显示生效值与来源 ─────────────────────────────────────────────────
203
+ function Show-Effective {
204
+ Write-Host "=== dsh-notify-say 配置 ===" -ForegroundColor Cyan
205
+ Write-Host (" DSH_HOME : {0}" -f $dshHome)
206
+ Write-Host (" CLI 覆盖层 : {0}" -f $configPath)
207
+
208
+ $cur = Read-ConfigDoc
209
+ if (-not $cur.ok) {
210
+ Write-Host (" ⚠️ 覆盖层损坏:{0}" -f $cur.reason) -ForegroundColor Yellow
211
+ Write-Host " 插件会忽略它并回退到上层配置;删掉该文件即可恢复。" -ForegroundColor Yellow
212
+ } elseif (-not $cur.exists) {
213
+ Write-Host " 状态: 不存在(全部走 设置页 / cordis.patch.yml)" -ForegroundColor DarkGray
214
+ } else {
215
+ Write-Host (" 状态: 有 {0} 项覆盖" -f $cur.values.Count) -ForegroundColor DarkGray
216
+ }
217
+
218
+ Write-Host ""
219
+ Write-Host "--- 覆盖层里的值(本文件优先级最高)---" -ForegroundColor Cyan
220
+ if ($cur.ok -and $cur.values.Count -gt 0) {
221
+ $cur.values.GetEnumerator() | Sort-Object Name | ForEach-Object {
222
+ $desc = if ($KindDesc.ContainsKey($_.Key)) { $KindDesc[$_.Key] } else { '' }
223
+ Write-Host (" {0,-20} = {1,-8} {2}" -f $_.Key, $_.Value, $desc)
224
+ }
225
+ } else {
226
+ Write-Host " (无)" -ForegroundColor DarkGray
227
+ }
228
+
229
+ # 从 patch 读基线(只读,不解释优先级,仅供对照)
230
+ $patch = Join-Path $pluginRoot 'cordis.patch.yml'
231
+ if (Test-Path -LiteralPath $patch) {
232
+ Write-Host ""
233
+ Write-Host "--- cordis.patch.yml 里的基线(会被上面覆盖)---" -ForegroundColor Cyan
234
+ Get-Content -LiteralPath $patch -Encoding UTF8 |
235
+ Where-Object { $_ -match '^\s+\w+:' } |
236
+ ForEach-Object { Write-Host (" " + $_.Trim()) }
237
+ }
238
+ Write-Host ""
239
+ Write-Host "提示:设置页的值介于两者之间,此处无法直接读取(需宿主接口)。" -ForegroundColor DarkGray
240
+ }
241
+
242
+ # ── -ListVoices:列音色及各池覆盖 ───────────────────────────────────────────
243
+ function Show-Voices {
244
+ $vl = Join-Path $pluginRoot 'sounds\voice-lines'
245
+ if (-not (Test-Path -LiteralPath $vl)) { Write-Host " 找不到语句池目录" -ForegroundColor Red; return }
246
+
247
+ # done 池根目录的音色 key(去掉 ~N 变体)
248
+ $doneKeys = @(Get-ChildItem -LiteralPath $vl -File -Filter '*.wav' -ErrorAction SilentlyContinue |
249
+ ForEach-Object { $_.BaseName -replace '~\d+$', '' } | Sort-Object -Unique)
250
+ foreach ($pool in @('question', 'approval', 'fail')) {
251
+ $d = Join-Path $vl $pool
252
+ if (Test-Path -LiteralPath $d) {
253
+ Set-Variable -Name "keys_$pool" -Value @(Get-ChildItem -LiteralPath $d -File -Filter '*.wav' -ErrorAction SilentlyContinue |
254
+ ForEach-Object { $_.BaseName -replace '~\d+$', '' } | Sort-Object -Unique)
255
+ }
256
+ }
257
+
258
+ Write-Host "=== 可用音色(共 $($doneKeys.Count) 个 key)===" -ForegroundColor Cyan
259
+ Write-Host " 标记含义:完成=有完成语音 提问/审批/失败=对应池是否覆盖" -ForegroundColor DarkGray
260
+ Write-Host ""
261
+ Write-Host (" {0,-26} {1,-6} {2,-6} {3,-6} {4}" -f '音色 key', '完成', '提问', '审批', '失败')
262
+ Write-Host (" " + ("-" * 62))
263
+ foreach ($k in $doneKeys) {
264
+ $q = if ((Get-Variable -Name 'keys_question' -ValueOnly -ErrorAction SilentlyContinue) -contains $k) { '✓' } else { '-' }
265
+ $a = if ((Get-Variable -Name 'keys_approval' -ValueOnly -ErrorAction SilentlyContinue) -contains $k) { '✓' } else { '-' }
266
+ $f = if ((Get-Variable -Name 'keys_fail' -ValueOnly -ErrorAction SilentlyContinue) -contains $k) { '✓' } else { '-' }
267
+ $warn = if ($f -eq '-') { ' ← 失败时无此音色台词' } else { '' }
268
+ Write-Host (" {0,-26} {1,-6} {2,-6} {3,-6} {4}{5}" -f $k, '✓', $q, $a, $f, $warn)
269
+ }
270
+ Write-Host ""
271
+ Write-Host "说明:某池未覆盖该音色时,v0.3.1+ 会在**同池内换一个音色**(不会哑掉,也不会越池播错语义)。" -ForegroundColor DarkGray
272
+ }
273
+
274
+ # ── 语音包管理:列出本地已装包 ──────────────────────────────────────────────
275
+ function Show-Packs {
276
+ $vl = Join-Path $pluginRoot 'sounds\voice-lines'
277
+ Write-Host "=== 已装语音包 ===" -ForegroundColor Cyan
278
+
279
+ if (-not (Test-Path -LiteralPath $vl)) {
280
+ Write-Host " 没有任何语音包。" -ForegroundColor DarkGray
281
+ Write-Host " → 通知会使用系统提示音 + Toast(这是正常状态,不是错误)。" -ForegroundColor DarkGray
282
+ Write-Host " → 用 -ListVoices 查看目录里有哪些音色 key 可选;用 -PurgeVoice <key> 删除。" -ForegroundColor DarkGray
283
+ return
284
+ }
285
+
286
+ # 按音色 key 汇总(去 ~N 变体)
287
+ $acc = @{}
288
+ foreach ($pool in @('', 'question', 'approval', 'fail')) {
289
+ $d = if ($pool) { Join-Path $vl $pool } else { $vl }
290
+ if (-not (Test-Path -LiteralPath $d)) { continue }
291
+ Get-ChildItem -LiteralPath $d -File -Filter '*.wav' -ErrorAction SilentlyContinue | ForEach-Object {
292
+ $k = $_.BaseName -replace '~\d+$', ''
293
+ if (-not $acc.ContainsKey($k)) { $acc[$k] = @{ pools = @(); bytes = 0; files = 0 } }
294
+ $pname = if ($pool) { $pool } else { 'done' }
295
+ if ($acc[$k].pools -notcontains $pname) { $acc[$k].pools += $pname }
296
+ $acc[$k].bytes += $_.Length
297
+ $acc[$k].files++
298
+ }
299
+ }
300
+
301
+ if ($acc.Count -eq 0) {
302
+ Write-Host " 目录存在但没有 wav 文件。" -ForegroundColor DarkGray
303
+ return
304
+ }
305
+
306
+ $totalMB = 0
307
+ Write-Host (" {0,-26} {1,-34} {2}" -f '音色 key', '覆盖的语句类', '体积')
308
+ Write-Host (" " + ("-" * 74))
309
+ foreach ($k in ($acc.Keys | Sort-Object)) {
310
+ $v = $acc[$k]
311
+ $mb = [math]::Round($v.bytes / 1MB, 2)
312
+ $totalMB += $mb
313
+ Write-Host (" {0,-26} {1,-34} {2,7:N2} MB" -f $k, ($v.pools -join ','), $mb)
314
+ }
315
+ Write-Host (" " + ("-" * 74))
316
+ Write-Host (" 合计 {0} 个音色 / {1:N2} MB" -f $acc.Count, $totalMB) -ForegroundColor Cyan
317
+ Write-Host ""
318
+ Write-Host " 说明:只装了 done 的音色,在失败时会**在同池内换音色**或回落提示音(不会播错语义)。" -ForegroundColor DarkGray
319
+ Write-Host " 完整语音包约 48 MB;核心包只需 1 个音色。删掉不用的用 -PurgeVoice <key>。" -ForegroundColor DarkGray
320
+ }
321
+
322
+ # ── 语音包管理:删除某音色 ──────────────────────────────────────────────────
323
+ function Remove-VoicePack([string]$key) {
324
+ if (-not $key) { Write-Host " 需要 -PurgeVoice <音色key>" -ForegroundColor Red; return }
325
+ if ($key -notmatch '^\w[\w-]*$') {
326
+ Write-Host " 非法音色 key:$key" -ForegroundColor Red; return
327
+ }
328
+ $vl = Join-Path $pluginRoot 'sounds\voice-lines'
329
+ $targets = @()
330
+ foreach ($pool in @('', 'question', 'approval', 'fail')) {
331
+ $d = if ($pool) { Join-Path $vl $pool } else { $vl }
332
+ if (-not (Test-Path -LiteralPath $d)) { continue }
333
+ Get-ChildItem -LiteralPath $d -File -Filter '*.wav' -ErrorAction SilentlyContinue | Where-Object {
334
+ ($_.BaseName -replace '~\d+$', '') -eq $key
335
+ } | ForEach-Object { $targets += $_ }
336
+ }
337
+ if ($targets.Count -eq 0) {
338
+ Write-Host " 未找到音色 '$key' 的任何 wav。" -ForegroundColor Yellow
339
+ return
340
+ }
341
+ $mb = [math]::Round(($targets | Measure-Object Length -Sum).Sum / 1MB, 2)
342
+ Write-Host (" 将删除 {0} 个文件 / {1:N2} MB(音色 {2})" -f $targets.Count, $mb, $key) -ForegroundColor Yellow
343
+ if ($DryRun) { Write-Host " [DryRun] 未删除" -ForegroundColor Yellow; return }
344
+ $n = 0
345
+ foreach ($t in $targets) {
346
+ try { Remove-Item -LiteralPath $t.FullName -Force; $n++ } catch { }
347
+ }
348
+ Write-Host (" 已删除 {0} 个文件。" -f $n) -ForegroundColor Green
349
+ if (-not (Test-Path -LiteralPath $vl) -or @(Get-ChildItem -LiteralPath $vl -Recurse -File -Filter '*.wav' -ErrorAction SilentlyContinue).Count -eq 0) {
350
+ Write-Host " 已无任何语音包 → 通知将使用系统提示音 + Toast(正常状态)。" -ForegroundColor DarkGray
351
+ }
352
+ }
353
+
354
+ # ── 语音包管理:说明如何获取更多 ────────────────────────────────────────────
355
+ function Show-InstallHint {
356
+ Write-Host "=== 如何获得更多语音 ===" -ForegroundColor Cyan
357
+ Write-Host ""
358
+ Write-Host " 核心包只带 1 个日语音色(约 1 MB)。完整多音色包体积较大,故不随包发布。" -ForegroundColor Gray
359
+ Write-Host ""
360
+ Write-Host " A) 自己合成(推荐,零分发成本、可自选音色)" -ForegroundColor Green
361
+ Write-Host " 1. 安装 VOICEVOX(本地免费 TTS 引擎,约 1 GB)"
362
+ Write-Host " 2. 启动引擎(默认 http://127.0.0.1:50021)"
363
+ Write-Host " 3. 跑仓库里的合成脚本:"
364
+ Write-Host " (合成脚本尚未随包发布;音色下载功能开发中)" -ForegroundColor DarkGray
365
+ Write-Host " 临时方案:手动在 VOICEVOX 里合成,把 wav 放进下面的 B) 目录"
366
+ Write-Host ""
367
+ Write-Host " B) 从他人镜像下载(如提供)" -ForegroundColor Green
368
+ Write-Host " 把 wav 放进 <插件根>\sounds\voice-lines\ 及子目录:"
369
+ Write-Host " done -> sounds\voice-lines\<key>~N.wav"
370
+ Write-Host " question -> sounds\voice-lines\question\<key>~N.wav"
371
+ Write-Host " approval -> sounds\voice-lines\approval\<key>~N.wav"
372
+ Write-Host " fail -> sounds\voice-lines\fail\<key>~N.wav"
373
+ Write-Host ""
374
+ Write-Host " C) 不装任何语音包(也是完全可用的配置)" -ForegroundColor Green
375
+ Write-Host " 通知 = Windows Toast + 系统提示音。用 -Voice off 可只留 Toast。" -ForegroundColor DarkGray
376
+ Write-Host ""
377
+ Write-Host " 装好后用 -ListPacks 查看,用 -VoiceName <key> 选定音色。" -ForegroundColor Gray
378
+ }
379
+
380
+ # ── 主逻辑 ──────────────────────────────────────────────────────────────────
381
+ if ($Show) { Show-Effective; return }
382
+ if ($ListVoices) { Show-Voices; return }
383
+ if ($ListPacks) { Show-Packs; return }
384
+ if ($InstallHint) { Show-InstallHint; return }
385
+ if ($PSBoundParameters.ContainsKey('PurgeVoice')) { Remove-VoicePack $PurgeVoice; return }
386
+
387
+ if ($Clear) {
388
+ if (-not (Test-Path -LiteralPath $configPath)) {
389
+ Write-Host " 覆盖层本来就不存在,无需清理。" -ForegroundColor DarkGray
390
+ return
391
+ }
392
+ if ($DryRun) { Write-Host " [DryRun] 将删除 $configPath" -ForegroundColor Yellow; return }
393
+ Remove-Item -LiteralPath $configPath -Force
394
+ Write-Host " 已删除覆盖层:$configPath" -ForegroundColor Green
395
+ Write-Host " 控制权已交还「设置页 / cordis.patch.yml」。" -ForegroundColor Green
396
+ return
397
+ }
398
+
399
+ # 组装变更
400
+ $cur = Read-ConfigDoc
401
+ if (-not $cur.ok) {
402
+ Write-Host " 拒绝写入:配置覆盖层已损坏($($cur.reason))" -ForegroundColor Red
403
+ Write-Host " 原文件保留在:$configPath" -ForegroundColor Red
404
+ Write-Host " 请先修复或删除它,再重试。" -ForegroundColor Red
405
+ exit 1
406
+ }
407
+
408
+ $vals = @{}
409
+ foreach ($k in $cur.values.Keys) { $vals[$k] = $cur.values[$k] }
410
+ $changed = @{}
411
+
412
+ if ($PSBoundParameters.ContainsKey('Voice')) {
413
+ $v = ($Voice -eq 'on')
414
+ $vals['voice'] = $v; $changed['voice'] = $v
415
+ }
416
+ if ($PSBoundParameters.ContainsKey('Volume')) {
417
+ $vals['volume'] = $Volume; $changed['volume'] = $Volume
418
+ }
419
+ if ($PSBoundParameters.ContainsKey('VoiceMode')) {
420
+ $vals['voiceMode'] = $VoiceMode; $changed['voiceMode'] = $VoiceMode
421
+ }
422
+ if ($PSBoundParameters.ContainsKey('VoiceName')) {
423
+ if ($VoiceName -notmatch '^\w[\w-]*$') {
424
+ Write-Host " 非法音色 key:$VoiceName(只允许字母数字下划线连字符)" -ForegroundColor Red
425
+ exit 1
426
+ }
427
+ $vals['voiceName'] = $VoiceName; $changed['voiceName'] = $VoiceName
428
+ }
429
+ foreach ($e in @($Enable)) {
430
+ if (-not $e) { continue }
431
+ foreach ($one in ($e -split '[,\s]+' | Where-Object { $_ })) {
432
+ $key = $KindMap[$one.ToLower()]
433
+ if (-not $key) {
434
+ Write-Host " 未知类别:$one(可用:$($KindMap.Keys -join ', '))" -ForegroundColor Red
435
+ exit 1
436
+ }
437
+ $vals[$key] = $true; $changed[$key] = $true
438
+ }
439
+ }
440
+ foreach ($d in @($Disable)) {
441
+ if (-not $d) { continue }
442
+ foreach ($one in ($d -split '[,\s]+' | Where-Object { $_ })) {
443
+ $key = $KindMap[$one.ToLower()]
444
+ if (-not $key) {
445
+ Write-Host " 未知类别:$one(可用:$($KindMap.Keys -join ', '))" -ForegroundColor Red
446
+ exit 1
447
+ }
448
+ $vals[$key] = $false; $changed[$key] = $false
449
+ }
450
+ }
451
+
452
+ if ($changed.Count -eq 0) {
453
+ Write-Host " 没有指定任何变更。用 -Show 查看当前值,或 -? 看用法。" -ForegroundColor Yellow
454
+ return
455
+ }
456
+
457
+ Write-Host "=== 将要变更 ===" -ForegroundColor Cyan
458
+ $changed.GetEnumerator() | Sort-Object Name | ForEach-Object {
459
+ $old = if ($cur.values.ContainsKey($_.Key)) { $cur.values[$_.Key] } else { '(未设)' }
460
+ $desc = if ($KindDesc.ContainsKey($_.Key)) { $KindDesc[$_.Key] } else { '' }
461
+ Write-Host (" {0,-20} {1} -> {2} {3}" -f $_.Key, $old, $_.Value, $desc)
462
+ }
463
+
464
+ if (-not (Write-ConfigDoc $vals)) { exit 1 }
465
+
466
+ if (-not $DryRun) {
467
+ Write-Host ""
468
+ Write-Host " ✓ 已保存。插件下次播报即生效,**无需重启**。" -ForegroundColor Green
469
+ if ($changed.ContainsKey('voice') -and $changed['voice'] -eq $false) {
470
+ Write-Host " 语音已关闭;Toast 仍会弹出。" -ForegroundColor DarkGray
471
+ }
472
+ }