dsh-live-speak 1.0.3 → 1.0.5

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/RELEASE-NOTES.md CHANGED
@@ -204,6 +204,48 @@ dsh-live-speak
204
204
 
205
205
  ---
206
206
 
207
+ ## 1.0.5 更新说明
208
+
209
+ **修好了「本地语音」在部分电脑上显示「未检测到可用声线」**
210
+
211
+ 有人反馈:设置页的「声线」下拉是空的,写着「未检测到可用声线」,可电脑里明明装着中文语音。
212
+
213
+ 问题不在语音包,在插件自己。它原来靠系统 `PATH` 去找 PowerShell 来读声线清单,而有些环境里 `PATH` 是被裁剪过的(精简启动器、部分终端、或以自动化方式拉起的进程),`powershell.exe` 叫不到,探测就静默失败、返回空列表——设置页于是显示「没有声线」。
214
+
215
+ 现在改成**直接按绝对路径调用系统自带的 PowerShell**:先查 `SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe`,找不到才退回按名字找。不再依赖 `PATH`。
216
+
217
+ 修完之后这台机器上认到的声线是 5 条:惠惠、瑶瑶、康康(Windows 新版语音层),加上惠惠桌面版、Zira(旧版桌面层);默认挑中文的「瑶瑶」,本地合成一路正常出声。
218
+
219
+ **说明**:这个毛病只影响「本地语音」档和设置页的下拉列表。用「线上语音」的话声线是固定的晓晓,从来没受影响。
220
+
221
+ 当前版本:**1.0.5**
222
+
223
+ ---
224
+
225
+ ## 1.0.4 更新说明
226
+
227
+ **新增:语音服务长时间没响应时,卡片会告诉你它在重试**
228
+
229
+ 线上语音偶尔会遇到服务端「连得上但不回话」——不是你的网络断了,是对方没在给数据。原来的表现是:卡片上一直写着「正在合成下一段…」,进度不动、声音不出来,看起来像插件死了。
230
+
231
+ 现在合成连续失败超过 15 秒,卡片那行字会变成「**语音服务暂无响应,正在重试(已 N 秒)**」。它同时做三件事:
232
+
233
+ - **说清状态**:你至少知道它在重试,不用去重启 DSH
234
+ - **给出现在**:秒数一直在走,能判断是真卡住还是只是慢
235
+ - **自己消失**:一恢复出声,或者你点了停止清空队列,这行字立刻撤掉
236
+
237
+ 失败照样一直重试、绝不跳段,只是这次你会看见它在做什么。
238
+
239
+ **同时修掉一个残留问题**:清空播放队列之后,那行「正在重试」原来会一直挂在卡片上(失败计时没跟着清),现在只在「确实还在合成」时才显示。
240
+
241
+ **怎么验证的**:本地把引擎切到「本地语音」制造确定性失败——卡片在第 21 秒出现「语音服务暂无响应,正在重试(已 21 秒)」;随后清空队列,这行字撤掉、卡片回到正常状态。另外在真实的线上服务端故障窗口里(18:12 起连续不可用、重试 18 次),卡片同样在第 24 秒给出了这行提示。
242
+
243
+ **一条不变的原则**:合成失败不跳过。任何一段合成不出来,就一直重试到成功为止,宁可晚几秒开口,也不让内容缺一段。
244
+
245
+ 历史版本:**1.0.4**
246
+
247
+ ---
248
+
207
249
  ## 1.0.3 更新说明
208
250
 
209
251
  **修掉:Linux 上本机没装语音后端时,会一直无声地重试**
@@ -221,7 +263,7 @@ macOS 上如果系统被裁剪到连 `say` 都没有,走同样的处理。**Wi
221
263
 
222
264
  **一条不变的原则**:合成失败不跳过。任何一段合成不出来,就一直重试到成功为止,宁可晚几秒开口,也不让内容缺一段。
223
265
 
224
- 当前版本:**1.0.3**
266
+ 历史版本:**1.0.3**
225
267
 
226
268
  ---
227
269
 
package/client/client.js CHANGED
@@ -64,6 +64,7 @@
64
64
  "empty.none": "没有待播内容",
65
65
  "empty.streaming": "正在生成下一句…",
66
66
  "empty.synth": "正在合成下一段…",
67
+ "empty.trouble": "语音服务暂无响应,正在重试(已 {s} 秒)",
67
68
  "act.pause": "暂停",
68
69
  "act.play": "播放 / 继续",
69
70
  "act.stop": "停止(清空队列)",
@@ -151,6 +152,7 @@
151
152
  "empty.none": "Nothing queued",
152
153
  "empty.streaming": "Generating the next line…",
153
154
  "empty.synth": "Voicing the next part…",
155
+ "empty.trouble": "Voice service not responding, retrying… ({s}s)",
154
156
  "act.pause": "Pause",
155
157
  "act.play": "Play / Resume",
156
158
  "act.stop": "Stop (clear queue)",
@@ -238,6 +240,7 @@
238
240
  "empty.none": "再生待ちはありません",
239
241
  "empty.streaming": "次の文を生成中…",
240
242
  "empty.synth": "次の部分を音声化中…",
243
+ "empty.trouble": "音声サービスが応答しません。再試行中…({s} 秒)",
241
244
  "act.pause": "一時停止",
242
245
  "act.play": "再生 / 再開",
243
246
  "act.stop": "停止(キューを消去)",
@@ -642,6 +645,8 @@
642
645
  // 空档里不能一律写"没有待播内容",那会让几秒静默看起来像卡死了。
643
646
  pending: 0,
644
647
  synthBusy: false,
648
+ // 合成连续失败的状态(宿主给):卡片据此说明"在重试",而不是装作没事
649
+ trouble: null,
645
650
  listeners: [],
646
651
  pollTimer: null,
647
652
  stopTimer: null
@@ -723,6 +728,7 @@
723
728
  // 后端还没合成完的段:空档期用来判断"在合成",而不是"没内容"
724
729
  pending: manager.pending || 0,
725
730
  synthBusy: manager.synthBusy === true,
731
+ trouble: manager.trouble,
726
732
  progress: manager.progress,
727
733
  duration: manager.duration,
728
734
  error: manager.error,
@@ -751,6 +757,11 @@
751
757
  * 中间两种以前都写成"没有待播内容",几秒静默看起来就像插件卡住了。
752
758
  */
753
759
  function idleLabel(state, t) {
760
+ // 合成连续失败十几秒是最要紧的信息:这时卡片本来该是静止的,
761
+ // 不写"在重试"用户只会以为插件死了,然后去重启它。
762
+ if (state.trouble && Number(state.trouble.seconds) >= 15) {
763
+ return t("empty.trouble").replace("{s}", String(state.trouble.seconds));
764
+ }
754
765
  if (state.error !== "") return state.error;
755
766
  if (state.streaming) return t("empty.streaming");
756
767
  if ((state.pending || 0) > 0 || state.synthBusy === true) return t("empty.synth");
@@ -1336,9 +1347,14 @@
1336
1347
  // 空档期给歌词区一个准确说法:还在合成 / 在等模型 / 真没内容
1337
1348
  var nextPending = Number(data.pending) || 0;
1338
1349
  var nextBusy = data.synthesizing === true;
1339
- if (nextPending !== manager.pending || nextBusy !== manager.synthBusy) {
1350
+ // 合成连续失败的状态:只在"从无到有"或秒数变化时重绘,避免每次轮询都白刷一遍
1351
+ var nextTrouble = data.trouble || null;
1352
+ var troubleChanged = (nextTrouble === null) !== (manager.trouble === null)
1353
+ || (nextTrouble !== null && manager.trouble !== null && nextTrouble.seconds !== manager.trouble.seconds);
1354
+ if (nextPending !== manager.pending || nextBusy !== manager.synthBusy || troubleChanged) {
1340
1355
  manager.pending = nextPending;
1341
1356
  manager.synthBusy = nextBusy;
1357
+ manager.trouble = nextTrouble;
1342
1358
  emit();
1343
1359
  }
1344
1360
  var appended = false;
package/index.js CHANGED
@@ -80,6 +80,27 @@ const IS_WIN = process.platform === 'win32'
80
80
  const IS_MAC = process.platform === 'darwin'
81
81
  const IS_LINUX = process.platform === 'linux'
82
82
 
83
+ /**
84
+ * Windows 上调 PowerShell 一律走这个路径,不再让 execFile 按 PATH 去猜。
85
+ *
86
+ * 之前直接写 'powershell.exe':PATH 被裁剪的环境里(精简启动器、部分终端或
87
+ * 自动化工具拉起的进程,PATH 里可能没有 System32)会直接 ENOENT,探测被
88
+ * catch 成空清单,设置页就显示「未检测到可用声线」——系统里明明装着 Huihui。
89
+ * 面向公众发行时这种机器不少,所以固定用绝对路径,找不到才退回按名查找。
90
+ */
91
+ const POWERSHELL_EXE = (() => {
92
+ if (!IS_WIN) return 'powershell.exe'
93
+ const root = process.env.SystemRoot || process.env.windir || 'C:\\Windows'
94
+ const candidates = [
95
+ path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
96
+ 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
97
+ ]
98
+ for (const candidate of candidates) {
99
+ if (fs.existsSync(candidate)) return candidate
100
+ }
101
+ return 'powershell.exe'
102
+ })()
103
+
83
104
  /**
84
105
  * 用户没指定声线时,按这个顺序挑一条。
85
106
  *
@@ -1139,6 +1160,12 @@ export function apply(ctx, config = {}) {
1139
1160
  let working = false
1140
1161
  let lastError = ''
1141
1162
  let synthFailCount = 0
1163
+ /**
1164
+ * 连续失败是从什么时候开始的(毫秒),0 表示当前正常。
1165
+ * 用来给界面一个明确说法:合成卡在门外十几秒时,用户需要知道"它在重试",
1166
+ * 而不是看着一张静止的卡片以为插件死了。
1167
+ */
1168
+ let synthTroubleSince = 0
1142
1169
 
1143
1170
  /** Edge TTS 实例懒加载:依赖缺失/网络异常都不该影响 DSH 启动。 */
1144
1171
  let engine = null
@@ -1178,6 +1205,8 @@ export function apply(ctx, config = {}) {
1178
1205
  ready.clear()
1179
1206
  jobs.length = 0
1180
1207
  if (epochChange) epoch += 1
1208
+ // 换一篇就把「合成异常」的计时归零,否则新一篇会带着上一篇的秒数继续报
1209
+ if (epochChange) synthTroubleSince = 0
1181
1210
  bump()
1182
1211
  }
1183
1212
 
@@ -1282,11 +1311,15 @@ export function apply(ctx, config = {}) {
1282
1311
  const probeWinrtVoices = () => new Promise((resolve) => {
1283
1312
  ensureWinrtScripts()
1284
1313
  execFile(
1285
- 'powershell.exe',
1314
+ POWERSHELL_EXE,
1286
1315
  ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', WINRT_PROBE_PS1],
1287
1316
  { timeout: 20_000, windowsHide: true },
1288
1317
  (error, stdout) => {
1289
- if (error) { resolve([]); return }
1318
+ if (error) {
1319
+ log(`voice probe failed: ${String(error?.message ?? error).split('\n')[0]}`)
1320
+ resolve([])
1321
+ return
1322
+ }
1290
1323
  resolve(String(stdout || '')
1291
1324
  .split('\n')
1292
1325
  .map((line) => line.trim())
@@ -1309,11 +1342,15 @@ export function apply(ctx, config = {}) {
1309
1342
  ].join('; ')
1310
1343
  const probeSapiVoices = () => new Promise((resolve) => {
1311
1344
  execFile(
1312
- 'powershell.exe',
1345
+ POWERSHELL_EXE,
1313
1346
  ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', SAPI_PROBE_CMD],
1314
1347
  { timeout: 20_000, windowsHide: true },
1315
1348
  (error, stdout) => {
1316
- if (error) { resolve([]); return }
1349
+ if (error) {
1350
+ log(`voice probe failed: ${String(error?.message ?? error).split('\n')[0]}`)
1351
+ resolve([])
1352
+ return
1353
+ }
1317
1354
  resolve(String(stdout || '')
1318
1355
  .split('\n')
1319
1356
  .map((line) => line.trim())
@@ -1334,7 +1371,11 @@ export function apply(ctx, config = {}) {
1334
1371
  */
1335
1372
  const probeSayVoices = () => new Promise((resolve) => {
1336
1373
  execFile('say', ['-v', '?'], { timeout: 15_000 }, (error, stdout) => {
1337
- if (error) { resolve([]); return }
1374
+ if (error) {
1375
+ log(`voice probe failed: ${String(error?.message ?? error).split('\n')[0]}`)
1376
+ resolve([])
1377
+ return
1378
+ }
1338
1379
  const list = []
1339
1380
  for (const raw of String(stdout || '').split('\n')) {
1340
1381
  const hit = /^(.+?)\s+([a-zA-Z]{2}[_-][a-zA-Z]{2,3}(?:[_-][A-Za-z0-9]+)?)\s+#/u.exec(raw)
@@ -1480,7 +1521,7 @@ export function apply(ctx, config = {}) {
1480
1521
  return
1481
1522
  }
1482
1523
  execFile(
1483
- 'powershell.exe',
1524
+ POWERSHELL_EXE,
1484
1525
  [
1485
1526
  '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', WINRT_SYNTH_PS1,
1486
1527
  '-TextFile', txtPath, '-OutWav', wavPath, '-VoiceMatch', voice ? voice.name : '', '-Rate', rateExpr,
@@ -1738,7 +1779,7 @@ export function apply(ctx, config = {}) {
1738
1779
  '$s.Dispose()',
1739
1780
  ].join('; ')
1740
1781
  execFile(
1741
- 'powershell.exe',
1782
+ POWERSHELL_EXE,
1742
1783
  ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
1743
1784
  {
1744
1785
  timeout: 25_000,
@@ -1902,6 +1943,7 @@ export function apply(ctx, config = {}) {
1902
1943
  ready.set(item.id, item)
1903
1944
  lastError = ''
1904
1945
  synthFailCount = 0
1946
+ synthTroubleSince = 0
1905
1947
  trimReady()
1906
1948
  // 日志里的声线名要写「真正发声的那条」:本地档是系统声线(瑶瑶/Huihui),
1907
1949
  // 不是 Edge 的 id,否则又会造出「显示晓晓、实际念瑶瑶」那种假记录
@@ -1911,6 +1953,10 @@ export function apply(ctx, config = {}) {
1911
1953
  } catch (error) {
1912
1954
  attempt += 1
1913
1955
  synthFailCount += 1
1956
+ // 第一次失败就记下起点,之后一直累积;成功一次就清零(见上面的 synth ok)。
1957
+ // 只认当前这一篇的失败:被清空/换篇之后才返回的旧尝试不算,
1958
+ // 否则点完"停止"卡片会一直挂着"正在重试",实际什么都没在做。
1959
+ if (epoch === myEpoch && synthTroubleSince === 0) synthTroubleSince = Date.now()
1914
1960
  lastError = String(error?.message ?? error).split('\n')[0]
1915
1961
  bump()
1916
1962
  if (epoch !== myEpoch) break
@@ -2141,6 +2187,17 @@ export function apply(ctx, config = {}) {
2141
2187
  voices: VOICES,
2142
2188
  error: lastError,
2143
2189
  failCount: synthFailCount,
2190
+ /**
2191
+ * 合成连续失败超过 15 秒时的状态,正常为 null。
2192
+ * 只在"确实还在合成"时报:清空队列、或者这一篇被推走之后,
2193
+ * pump 已经不在跑,残留的失败计时不该继续挂在卡片上。
2194
+ */
2195
+ trouble: (synthTroubleSince === 0 || !working)
2196
+ ? null
2197
+ : (() => {
2198
+ const seconds = Math.round((Date.now() - synthTroubleSince) / 1000)
2199
+ return seconds >= 15 ? { seconds, error: lastError } : null
2200
+ })(),
2144
2201
  }
2145
2202
  }
2146
2203
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-live-speak",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "用高保真神经语音实时播报模型输出(边生成边念),自带播放器,可随时暂停/恢复/停止。",
5
5
  "type": "module",
6
6
  "main": "./index.js",