dsh-live-speak 1.0.0 → 1.0.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/index.js CHANGED
@@ -14,6 +14,8 @@
14
14
  import fs from 'node:fs'
15
15
  import os from 'node:os'
16
16
  import path from 'node:path'
17
+ import { execFile } from 'node:child_process'
18
+ import { fileURLToPath } from 'node:url'
17
19
 
18
20
  /** Cordis 里这一行的名字。 */
19
21
  export const name = 'live-speak'
@@ -34,13 +36,75 @@ const MAX_READY = 60
34
36
  const READY_TTL_MS = 15 * 60 * 1000
35
37
  /** 单段合成的字数上限(Edge TTS 对长文本会明显变慢)。 */
36
38
  const MAX_SEGMENT_CHARS = 300
37
- /** 第一段的字数上限:短一点,让声音早点出来。 */
38
- const FIRST_CHUNK_CHARS = 45
39
- /** 合成失败的重试次数与基准等待,429 走指数退避。 */
40
- const SYNTH_RETRIES = 4
39
+ /**
40
+ * 第一段的字数上限。
41
+ *
42
+ * 曾经是 45:为了让第一声尽早出来。但它只播 9~11 秒,几乎是全篇容错窗口最薄的地方——
43
+ * 实测一篇 434 字的文章,首块 45 字播 9.4 秒,第二段 228 字这次 Edge 合成用了 15 秒,
44
+ * 首块播完就空等 5.5 秒,开头直接断。
45
+ *
46
+ * 提到 130:第一声晚 5~6 秒出来,换来开头那段能播 26~30 秒,扛得住一次 8 秒超时加退避。
47
+ * 宁可晚开口,也不能开口之后就断。
48
+ */
49
+ const FIRST_CHUNK_CHARS = 130
50
+ /**
51
+ * 合成失败的重试次数与基准等待,429 走指数退避。
52
+ *
53
+ * 这一层是"快速自救":单次 8 秒没数据就算超时,退避 0.9 秒再试一次。
54
+ * 两次都不成,就交给合成泵里那层不放弃的重试(见 pump)。
55
+ * 代价只在失败时:最坏 8 秒超时 + 0.9 秒退避 + 8 秒超时 = 17 秒,仍在一段 60 秒的播报窗口内。
56
+ */
57
+ const SYNTH_RETRIES = 2
41
58
  const SYNTH_RETRY_BASE_MS = 900
42
- // 同时最多几路合成。串行时一段被 429 退避拖慢会把后面全堵住,实测出现过 8 秒空白
43
- const MAX_SYNTH_PARALLEL = 2
59
+ /** 不放弃重试的退避上限:一直失败也不会把等待拉到天荒地老,15 秒一次接着试。 */
60
+ const SYNTH_RETRY_MAX_MS = 15 * 1000
61
+ /** Edge 单次合成「多久没有数据就放弃」:正常 2.5~7 秒,卡住的是 21.5 秒,8 秒足够分辨。 */
62
+ const EDGE_TIMEOUT_MS = 8000
63
+ /**
64
+ * 单次合成的总时长硬上限(看门狗)。
65
+ *
66
+ * EDGE_TIMEOUT_MS 只管连接/首包——实测把它压到 8 秒,真卡住的那次是 23.4 秒才由
67
+ * WebSocket inactivity 报错,8 秒根本没生效。合成阶段一旦卡死,没有这层就会把整篇
68
+ * 播报永远冻在第一段。正常合成实测 4.5~8.4 秒,20 秒留了两倍余量,超了就当失败重试。
69
+ */
70
+ const SYNTH_DEADLINE_MS = 20 * 1000
71
+ /** 本地兜底用的 Windows SAPI 声线(老式机器音,作为最后一级兜底)。 */
72
+ const LOCAL_VOICE = 'Microsoft Yaoyao'
73
+ /** 本地合成的 WAV 落地目录(合成完立刻读回内存并删除)。 */
74
+ const LOCAL_AUDIO_DIR = path.join(os.homedir(), '.dsh', 'dsh-live-speak', 'local-audio')
75
+ /** 插件自身根目录:ESM 里没有 __dirname,用 import.meta.url 反解。 */
76
+ const PLUGIN_ROOT = path.dirname(fileURLToPath(import.meta.url))
77
+
78
+ /** 三端分派:Windows 走 WinRT/SAPI,macOS 走 say,Linux 走 espeak-ng。 */
79
+ const IS_WIN = process.platform === 'win32'
80
+ const IS_MAC = process.platform === 'darwin'
81
+ const IS_LINUX = process.platform === 'linux'
82
+
83
+ /**
84
+ * 用户没指定声线时,按这个顺序挑一条。
85
+ *
86
+ * 前几位是 Windows 11 的「自然语音」——晓晓、晓伊、云希这些神经语音,
87
+ * 音色与 Edge 云端那批同源,但完全在本机推理、不联网、100 毫秒级出音。
88
+ * 它们只能通过 WinRT 的 Windows.Media.SpeechSynthesis 取到;SAPI
89
+ * (System.Speech)那一层看不见它们,这正是早前「本机没有晓晓」误判的来源。
90
+ * 后面三条是 macOS 与 Linux 的中文声线。一个都没命中时,才落到 list[0]。
91
+ */
92
+ const LOCAL_VOICE_PREFER = [
93
+ 'Xiaoxiao', 'Xiaoyi', 'Yunxi', 'Yunyang', 'Yunjian', 'Yaoyao',
94
+ 'Tingting', 'Meijia', 'Sinji', 'Huihui', 'Kangkang',
95
+ ]
96
+ /** 本地声线清单里,语言一律用 BCP-47 风格;这几种都算中文。 */
97
+ const RE_ZH_LANG = /^zh|^cmn|^yue|Chinese/i
98
+ /** WinRT 脚本落在运行时目录,随插件写出来,不塞进 npm 包体。 */
99
+ const WINRT_SYNTH_PS1 = path.join(LOCAL_AUDIO_DIR, 'winrt-synth.ps1')
100
+ const WINRT_PROBE_PS1 = path.join(LOCAL_AUDIO_DIR, 'winrt-probe.ps1')
101
+ // 同一时刻只有一路合成在途,并且 ready 里最多备 2 段(1 段在播、1 段等播)。
102
+ // 300 字一段要播约 60 秒,而合成一段只要 5~10 秒,余量六倍以上,
103
+ // "合成下一段"永远赶在"上一段播完"之前。单路顺带解决了两个老问题:
104
+ // 并发撞 429,以及两路乱序完成时最小 id 反而最晚落地、把播放口堵住。
105
+ const MAX_SYNTH_PARALLEL = 1
106
+ /** 合成结果最多领先播放几段。1 = 正在播的那段之后还能备好 1 段。 */
107
+ const MAX_SYNTH_LEAD = 2
44
108
  /*
45
109
  * 注意:这里曾经有一个 LIVE_REPLAY_WINDOW_MS = 180_000 的时间窗,用来判断
46
110
  * 「实时路径刚刚送过东西,整段落地的消息只补尾巴」。它被删掉了,原因是
@@ -117,6 +181,27 @@ const DEFAULT_SETTINGS = {
117
181
  * 只在 dock = sidebar 时有意义,顶栏形态没有展开卡片。
118
182
  */
119
183
  dockExpanded: false,
184
+ /**
185
+ * 用哪个引擎合成。
186
+ * - 'edge'(默认):只用 Edge 神经语音,音色自然。慢的时候第一句要等 4~7 秒,
187
+ * 个别段超时会直接跳过——不换成系统声线,保证整篇是同一个人念的。
188
+ * - 'local':只用 Windows 本地声线。实测 28 字约 282ms,起播最快、长文不断流,
189
+ * 代价是音色是上一代机器音。
190
+ * - 'auto':老配置遗留值,行为同 'edge'(不再自动降级,避免一篇里两种声音)。
191
+ */
192
+ ttsEngine: 'edge',
193
+ /**
194
+ * 本地档用哪条声线(存声线名,空字符串=让插件按平台自动挑一条最优的)。
195
+ * 清单由宿主实时探测,用户装了新语音包会出现在设置页里,选谁就用谁。
196
+ */
197
+ localVoice: '',
198
+ /**
199
+ * 播放端均衡器:10 段(31 / 63 / 125 / 250 / 500 / 1k / 2k / 4k / 8k / 16k Hz),
200
+ * 每段增益 -12~+12 dB。
201
+ * 只在客户端播放链路里生效(Web Audio 的 BiquadFilter 串在 Audio 元素之后),
202
+ * 不改合成结果,所以拖动滑块能立刻听到变化、也不用重新合成。
203
+ */
204
+ eq: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
120
205
  }
121
206
 
122
207
  // ──────────────────────────────────────────────────────────────
@@ -155,6 +240,22 @@ function normalizeSettings(raw) {
155
240
  codeQuiet: pickBool(src.codeQuiet, DEFAULT_SETTINGS.codeQuiet),
156
241
  dockExpanded: pickBool(src.dockExpanded, DEFAULT_SETTINGS.dockExpanded),
157
242
  dock: ['top', 'sidebar'].includes(src.dock) ? src.dock : DEFAULT_SETTINGS.dock,
243
+ ttsEngine: ['edge', 'local', 'auto'].includes(src.ttsEngine)
244
+ ? src.ttsEngine
245
+ : DEFAULT_SETTINGS.ttsEngine,
246
+ // 本地声线名:不在这里对照清单校验——清单是运行时探测的,
247
+ // 用户选的那条万一被卸载,取值时再回落到自动挑选,不让配置把插件弄哑。
248
+ localVoice: typeof src.localVoice === 'string' ? src.localVoice.trim().slice(0, 120) : '',
249
+ // 均衡器:固定 10 段,非数字回落 0,越界夹到 ±12 dB
250
+ eq: (() => {
251
+ const arr = Array.isArray(src.eq) ? src.eq : []
252
+ const out = []
253
+ for (let i = 0; i < 10; i += 1) {
254
+ const n = Number(arr[i])
255
+ out.push(Number.isFinite(n) ? Math.max(-12, Math.min(12, Math.round(n * 10) / 10)) : 0)
256
+ }
257
+ return out
258
+ })(),
158
259
  }
159
260
  }
160
261
 
@@ -284,6 +385,13 @@ function fileKindLabel(file) {
284
385
  * 把模型输出的 Markdown 洗成"能念的普通话":
285
386
  * 代码块、表格、URL、标记符号都不该被念出来。
286
387
  */
388
+ /**
389
+ * 标题行没写成 Markdown 时(模型常用加粗或直接独占一行)的补救:逐行补标点那一步,
390
+ * 遇到"短、没有逗号、也没有句末标点"的一行,补省略号而不是句号。
391
+ * 句号的停顿只有 200 毫秒,听起来仍是「帘卷西风济南的趵突泉边」;省略号才够长。
392
+ */
393
+ const TITLE_LINE_MAX = 20
394
+
287
395
  function cleanForSpeech(raw) {
288
396
  let text = String(raw ?? '')
289
397
  if (text === '') return ''
@@ -342,7 +450,9 @@ function cleanForSpeech(raw) {
342
450
  // 没写闭合围栏的代码块:从围栏开头整段丢掉(剩下的都是代码,念出来全是花括号分号)
343
451
  text = text.replace(/```[\s\S]*$/gu, '\n')
344
452
  text = text.replace(/`([^`]*)`/gu, '$1')
345
- text = text.replace(/^\s{0,3}#{1,6}\s+(.+)$/gmu, (match, title) => (/[。!?!?::,,]$/u.test(title) ? title : `${title}。`))
453
+ // 标题行不补句号,改补省略号:句号只给 200 毫秒,听起来还是"帘卷西风济南的趵突泉边"一句;
454
+ // 省略号的停顿明显长一截,标题和正文才听得出是两件事。
455
+ text = text.replace(/^\s{0,3}#{1,6}\s+(.+)$/gmu, (match, title) => (/[。!?!?::,,…]$/u.test(title) ? title : `${title}……`))
346
456
  text = text.replace(/^\s{0,3}>\s?/gmu, '')
347
457
  text = text.replace(/^\s{0,3}[-*+]\s+/gmu, '')
348
458
  text = text.replace(/^\s{0,3}\d+[.)]\s+/gmu, '')
@@ -367,7 +477,12 @@ function cleanForSpeech(raw) {
367
477
  // 没被围栏包住、直接摊在正文里的代码行:念出来是「padding 10 px」「background color」
368
478
  .filter((line) => line !== '' && !looksLikeCodeLine(line))
369
479
  text = lines
370
- .map((line) => (/[。!?!?…;;::,,]$/u.test(line) ? line : `${line}。`))
480
+ .map((line) => {
481
+ if (/[。!?!?…;;::,,]$/u.test(line)) return line
482
+ // 短、没逗号的一行,基本就是没写成 Markdown 的小标题:补省略号,让停顿明显
483
+ if (line.length <= TITLE_LINE_MAX && !/[,,::;;]/u.test(line)) return `${line}……`
484
+ return `${line}。`
485
+ })
371
486
  .join('\n')
372
487
  return text.trim()
373
488
  }
@@ -788,6 +903,10 @@ function chunkText(text, targetChars, firstChunkChars = 0) {
788
903
  for (const sentence of sentences) {
789
904
  const pieces = sentence.length > limit * 1.5 ? splitLongSentence(sentence, limit) : [sentence]
790
905
  for (const piece of pieces) {
906
+ // 标题行到这里已经是普通句子了(cleanForSpeech 在它后面补了省略号)。
907
+ // 不能让它单独成一段:4 个字的标题只播 1.5 秒,而下一段 76 字要合成 8 秒,
908
+ // 播完标题就开始空等——实测就是 5~7 秒的空白,比连读难听得多。
909
+ // 停顿由省略号给,节奏还由 300 字的大块保证。
791
910
  if (current !== '' && current.length + piece.length > limit) {
792
911
  chunks.push(current)
793
912
  current = ''
@@ -816,14 +935,12 @@ function chunkText(text, targetChars, firstChunkChars = 0) {
816
935
  // 首段单独缩短:第一段越快合成完,用户越早听到声音
817
936
  if (firstChunkChars > 0 && merged.length > 0 && merged[0].length > firstChunkChars) {
818
937
  const head = splitLongSentence(merged[0], Math.max(20, firstChunkChars))
819
- // 尾部碎片("等得久了。"这种十几字)并回前一段:
820
- // 否则会多出一个 13 字的独立段,既多一次合成,又多一次启停。
821
- const fixed = []
822
- for (const piece of head) {
823
- if (fixed.length > 0 && piece.length < 15) fixed[fixed.length - 1] += piece
824
- else fixed.push(piece)
825
- }
826
- merged.splice(0, 1, ...fixed)
938
+ // 只拿第一小段当前置首块,剩下的整段并回第二块。
939
+ // 以前是把它拆成五六段,结果每一小段都要单独去服务端排一次队:
940
+ // 首句没更快,反而多了五次超时机会(日志里一篇 13 段有 6 段是这种碎片)。
941
+ const lead = head[0]
942
+ const rest = head.slice(1).join('')
943
+ merged.splice(0, 1, ...(rest.trim() === '' ? [lead] : [lead, rest]));
827
944
  }
828
945
  return merged
829
946
  }
@@ -1095,23 +1212,612 @@ export function apply(ctx, config = {}) {
1095
1212
  return chosen
1096
1213
  }
1097
1214
 
1098
- /** 调用 Edge 合成一段;429/网络错走指数退避,别一段就把整轮播报拖死。 */
1215
+ const WINRT_SYNTH_SCRIPT = [
1216
+ 'param([string]$TextFile,[string]$OutWav,[string]$VoiceMatch,[string]$Rate)',
1217
+ 'Add-Type -AssemblyName System.Runtime.WindowsRuntime | Out-Null',
1218
+ "$asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' })[0]",
1219
+ 'function Await($task) {',
1220
+ ' $net = $asTaskGeneric.MakeGenericMethod([Windows.Media.SpeechSynthesis.SpeechSynthesisStream]).Invoke($null, @($task))',
1221
+ ' $net.Wait(-1) | Out-Null',
1222
+ ' $net.Result',
1223
+ '}',
1224
+ '[void][Windows.Media.SpeechSynthesis.SpeechSynthesizer,Windows.Media.SpeechSynthesis,ContentType=WindowsRuntime]',
1225
+ '$synth = New-Object Windows.Media.SpeechSynthesis.SpeechSynthesizer',
1226
+ '$voices = [Windows.Media.SpeechSynthesis.SpeechSynthesizer]::AllVoices',
1227
+ '$target = $null',
1228
+ 'if ($VoiceMatch) { $target = $voices | Where-Object { $_.DisplayName -like "*$VoiceMatch*" } | Select-Object -First 1 }',
1229
+ "if ($null -eq $target) { $target = $voices | Where-Object { $_.Language -like 'zh*' } | Select-Object -First 1 }",
1230
+ "if ($null -eq $target) { throw 'NO_LOCAL_VOICE' }",
1231
+ '$synth.Voice = $target',
1232
+ '$body = Get-Content -LiteralPath $TextFile -Raw -Encoding utf8',
1233
+ "if (-not $body) { throw 'EMPTY_TEXT' }",
1234
+ '$escaped = [System.Security.SecurityElement]::Escape($body)',
1235
+ '$ssml = \'<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="zh-CN"><prosody rate="\' + $Rate + \'">\' + $escaped + \'</prosody></speak>\'',
1236
+ 'try { $stream = Await ($synth.SynthesizeSsmlToStreamAsync($ssml)) }',
1237
+ 'catch { $stream = Await ($synth.SynthesizeTextToStreamAsync($body)) }',
1238
+ '$src = [System.IO.WindowsRuntimeStreamExtensions]::AsStreamForRead($stream)',
1239
+ '$fs = [System.IO.File]::Create($OutWav)',
1240
+ '$src.CopyTo($fs)',
1241
+ '$fs.Close()',
1242
+ '$src.Close()',
1243
+ '$synth.Dispose()',
1244
+ "'VOICE=' + $target.DisplayName",
1245
+ ].join('\r\n')
1246
+
1247
+ const WINRT_PROBE_SCRIPT = [
1248
+ '[void][Windows.Media.SpeechSynthesis.SpeechSynthesizer,Windows.Media.SpeechSynthesis,ContentType=WindowsRuntime]',
1249
+ '$voices = [Windows.Media.SpeechSynthesis.SpeechSynthesizer]::AllVoices',
1250
+ "foreach ($v in $voices) { $v.DisplayName + '|' + $v.Language + '|' + $v.Id }",
1251
+ ].join('\r\n')
1252
+
1253
+ /**
1254
+ * 两个脚本每次都按内容比对写一遍,内容没变就不落盘。
1255
+ * 放在运行时目录而不是插件目录,是为了 npm 包里不带可变文件。
1256
+ */
1257
+ const ensureWinrtScripts = () => {
1258
+ try {
1259
+ fs.mkdirSync(LOCAL_AUDIO_DIR, { recursive: true })
1260
+ const writeIfChanged = (target, content) => {
1261
+ let previous = ''
1262
+ try { previous = fs.readFileSync(target, 'utf8') } catch { /* 没有就当空 */ }
1263
+ if (previous !== content) fs.writeFileSync(target, content, 'utf8')
1264
+ }
1265
+ writeIfChanged(WINRT_SYNTH_PS1, WINRT_SYNTH_SCRIPT)
1266
+ writeIfChanged(WINRT_PROBE_PS1, WINRT_PROBE_SCRIPT)
1267
+ } catch { /* 写不进去就让上层回落到 SAPI */ }
1268
+ }
1269
+
1270
+ // ── 本机声线探测(三端统一) ──────────────────────────────
1271
+ // 统一结构 { name, id, lang, engine }:
1272
+ // engine = winrt / sapi / say / espeak —— 合成时按它分派到对应后端
1273
+ // id = 该后端内部指定声线用的值(Windows 用声线名,say/espeak 也用名字)
1274
+ // 清单缓存 5 分钟;设置页点「重新检测」会强制清缓存立刻重探。
1275
+
1276
+ /** Windows:WinRT 那一层。自然语音(晓晓这类)只在这里,SAPI 看不见。 */
1277
+ const probeWinrtVoices = () => new Promise((resolve) => {
1278
+ ensureWinrtScripts()
1279
+ execFile(
1280
+ 'powershell.exe',
1281
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', WINRT_PROBE_PS1],
1282
+ { timeout: 20_000, windowsHide: true },
1283
+ (error, stdout) => {
1284
+ if (error) { resolve([]); return }
1285
+ resolve(String(stdout || '')
1286
+ .split('\n')
1287
+ .map((line) => line.trim())
1288
+ .filter(Boolean)
1289
+ .map((line) => {
1290
+ const [name, lang, id] = line.split('|')
1291
+ return { name: name || '', id: id || name || '', lang: lang || '', engine: 'winrt' }
1292
+ })
1293
+ .filter((voice) => voice.name !== ''))
1294
+ },
1295
+ )
1296
+ })
1297
+
1298
+ /** Windows:SAPI 那一层。老式桌面声线(Huihui / Kangkang / Yaoyao)在这里。 */
1299
+ const SAPI_PROBE_CMD = [
1300
+ 'Add-Type -AssemblyName System.Speech',
1301
+ '$s = New-Object System.Speech.Synthesis.SpeechSynthesizer',
1302
+ 'foreach ($v in $s.GetInstalledVoices()) { if ($v.Enabled) { $i = $v.VoiceInfo; $i.Name + "|" + $i.Culture.Name } }',
1303
+ '$s.Dispose()',
1304
+ ].join('; ')
1305
+ const probeSapiVoices = () => new Promise((resolve) => {
1306
+ execFile(
1307
+ 'powershell.exe',
1308
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', SAPI_PROBE_CMD],
1309
+ { timeout: 20_000, windowsHide: true },
1310
+ (error, stdout) => {
1311
+ if (error) { resolve([]); return }
1312
+ resolve(String(stdout || '')
1313
+ .split('\n')
1314
+ .map((line) => line.trim())
1315
+ .filter(Boolean)
1316
+ .map((line) => {
1317
+ const [name, lang] = line.split('|')
1318
+ return { name: name || '', id: name || '', lang: lang || '', engine: 'sapi' }
1319
+ })
1320
+ .filter((voice) => voice.name !== ''))
1321
+ },
1322
+ )
1323
+ })
1324
+
1325
+ /**
1326
+ * macOS:`say -v ?` 的固定列输出,形如
1327
+ * Tingting zh_CN # 你好,我叫Tingting。
1328
+ * 声线名可能带空格,所以用「空白 + 语言标签 + #」从右侧圈定,避免按列硬切。
1329
+ */
1330
+ const probeSayVoices = () => new Promise((resolve) => {
1331
+ execFile('say', ['-v', '?'], { timeout: 15_000 }, (error, stdout) => {
1332
+ if (error) { resolve([]); return }
1333
+ const list = []
1334
+ for (const raw of String(stdout || '').split('\n')) {
1335
+ const hit = /^(.+?)\s+([a-zA-Z]{2}[_-][a-zA-Z]{2,3}(?:[_-][A-Za-z0-9]+)?)\s+#/u.exec(raw)
1336
+ if (hit === null) continue
1337
+ const name = hit[1].trim()
1338
+ if (name === '') continue
1339
+ list.push({ name, id: name, lang: hit[2], engine: 'say' })
1340
+ }
1341
+ resolve(list)
1342
+ })
1343
+ })
1344
+
1345
+ /**
1346
+ * Linux:espeak-ng(或老版 espeak)的 --voices 表格,第 2 列语言代码、第 4 列声线名。
1347
+ * 优先 espeak-ng,没装再退回 espeak;两个都探不到就返回空清单。
1348
+ */
1349
+ let espeakBinary = ''
1350
+ const probeEspeakVoices = () => new Promise((resolve) => {
1351
+ const tryBin = (bin, next) => {
1352
+ execFile(bin, ['--voices'], { timeout: 15_000 }, (error, stdout) => {
1353
+ if (error) { next(); return }
1354
+ espeakBinary = bin
1355
+ const list = []
1356
+ for (const raw of String(stdout || '').split('\n')) {
1357
+ const cols = raw.trim().split(/\s+/u)
1358
+ // 表头 "Pty Language Age/Gender VoiceName File ..." 第一列不是数字,直接跳过
1359
+ if (cols.length < 4 || !/^\d+$/u.test(cols[0])) continue
1360
+ // espeak 的 VoiceName 列本身带空格(如 "Chinese (Mandarin)"),
1361
+ // 用语言代码当 id(espeak-ng -v cmn 认这个),显示名把两段拼起来便于辨认
1362
+ list.push({ name: `${cols[1]} ${cols[3]}`.trim(), id: cols[1], lang: cols[1], engine: 'espeak' })
1363
+ }
1364
+ resolve(list)
1365
+ })
1366
+ }
1367
+ // 两个都没有时留一条明确日志:Linux 本地档没声音就是这个原因,别让人猜
1368
+ tryBin('espeak-ng', () => tryBin('espeak', () => {
1369
+ log('本地语音不可用:本机既没有 espeak-ng 也没有 espeak,装任意一个即可')
1370
+ resolve([])
1371
+ }))
1372
+ })
1373
+
1374
+ /** 按名字去重:同名保留先出现的(Windows 上 WinRT 排在 SAPI 前面,那一份音质更好)。 */
1375
+ const dedupeVoices = (list) => {
1376
+ const seen = new Set()
1377
+ const out = []
1378
+ for (const voice of list) {
1379
+ const key = String(voice.name || '').toLowerCase()
1380
+ if (key === '' || seen.has(key)) continue
1381
+ seen.add(key)
1382
+ out.push(voice)
1383
+ }
1384
+ return out
1385
+ }
1386
+
1387
+ const probeLocalVoicesNow = () => {
1388
+ if (IS_MAC) return probeSayVoices()
1389
+ if (IS_WIN) {
1390
+ return Promise.all([probeWinrtVoices(), probeSapiVoices()])
1391
+ .then(([winrt, sapi]) => dedupeVoices([...winrt, ...sapi]))
1392
+ }
1393
+ return probeEspeakVoices()
1394
+ }
1395
+
1396
+ /** 本机声线清单,缓存 5 分钟(中途装语音包时点「重新检测」可立刻刷新)。 */
1397
+ let localVoicesCache = null
1398
+ /** 给 /state 与设置页用的快照:挂载后异步探一次。 */
1399
+ let localVoicesSnapshot = []
1400
+ let localVoiceSnapshot = null
1401
+ const probeLocalVoices = () => new Promise((resolve) => {
1402
+ const now = Date.now()
1403
+ if (localVoicesCache && now - localVoicesCache.at < 5 * 60 * 1000) {
1404
+ resolve(localVoicesCache.list)
1405
+ return
1406
+ }
1407
+ probeLocalVoicesNow()
1408
+ .then((list) => {
1409
+ localVoicesCache = { at: now, list }
1410
+ resolve(list)
1411
+ })
1412
+ .catch(() => resolve([]))
1413
+ })
1414
+
1415
+ /**
1416
+ * 挑一条要用的本机声线:用户选的那条优先;它被卸载了就按优选顺序重挑。
1417
+ * 顺序是「偏好表命中 → 中文声线 → 清单第一条」,保证总有声音,不会因为
1418
+ * 用户选过的声线消失而变成哑巴。
1419
+ */
1420
+ const pickLocalVoice = (list) => {
1421
+ if (!Array.isArray(list) || list.length === 0) return null
1422
+ const want = String(settings.localVoice || '')
1423
+ if (want !== '') {
1424
+ const hit = list.find((voice) => voice.name === want || voice.id === want)
1425
+ if (hit) return hit
1426
+ }
1427
+ for (const key of LOCAL_VOICE_PREFER) {
1428
+ const hit = list.find((voice) => String(voice.name).toLowerCase().includes(key.toLowerCase()))
1429
+ if (hit) return hit
1430
+ }
1431
+ const zhHit = list.find((voice) => RE_ZH_LANG.test(String(voice.lang || '')))
1432
+ return zhHit || list[0]
1433
+ }
1434
+
1435
+ /**
1436
+ * 本机有没有「自然语音」(晓晓这类神经语音)。
1437
+ * Windows 上只有 Huihui/Kangkang/Yaoyao 这几个老式机器音时返回 false。
1438
+ */
1439
+ const hasNaturalLocalVoice = (list) => list.some((voice) => /Xiaoxiao|Xiaoyi|Yunxi|Yunyang|Yunjian/i.test(voice.name))
1440
+
1441
+ /** 挂载后探一次本机语音,结果同时供 /state 与设置页使用。失败不重试、不影响播报。 */
1442
+ const refreshLocalVoices = (force = false) => {
1443
+ if (force) localVoicesCache = null
1444
+ return probeLocalVoices()
1445
+ .then((list) => {
1446
+ localVoicesSnapshot = list
1447
+ localVoiceSnapshot = pickLocalVoice(list)
1448
+ // 这条日志是「本机到底探到几条声线、走哪个后端」的唯一现场证据
1449
+ log(`local voices: n=${list.length} pick=${localVoiceSnapshot ? `${localVoiceSnapshot.name}/${localVoiceSnapshot.engine}` : 'none'} [${list.map((voice) => `${voice.name}/${voice.engine}`).join(', ')}]`)
1450
+ bump()
1451
+ return list
1452
+ })
1453
+ .catch(() => localVoicesSnapshot)
1454
+ }
1455
+
1456
+ /**
1457
+ * WinRT 本地合成。自然语音(晓晓这类)只挂在 Windows.Media.SpeechSynthesis 这一层,
1458
+ * SAPI 的 GetInstalledVoices 看不见它们,所以这两条路要分开走。
1459
+ */
1460
+ const synthesizeLocalWinRT = (text, voice) => new Promise((resolve, reject) => {
1461
+ const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
1462
+ const txtPath = path.join(LOCAL_AUDIO_DIR, `winrt-${stamp}.txt`)
1463
+ const wavPath = path.join(LOCAL_AUDIO_DIR, `winrt-${stamp}.wav`)
1464
+ const edgeRate = Number(String(settings.rate || '0').replace('%', '')) || 0
1465
+ const rateExpr = `${edgeRate > 0 ? '+' : ''}${Math.max(-50, Math.min(50, edgeRate))}%`
1466
+ const cleanup = () => {
1467
+ try { fs.unlinkSync(txtPath) } catch { /* 已经不在就算了 */ }
1468
+ try { fs.unlinkSync(wavPath) } catch { /* 同上 */ }
1469
+ }
1470
+ try {
1471
+ ensureWinrtScripts()
1472
+ fs.writeFileSync(txtPath, text, 'utf8')
1473
+ } catch (error) {
1474
+ reject(error)
1475
+ return
1476
+ }
1477
+ execFile(
1478
+ 'powershell.exe',
1479
+ [
1480
+ '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', WINRT_SYNTH_PS1,
1481
+ '-TextFile', txtPath, '-OutWav', wavPath, '-VoiceMatch', voice ? voice.name : '', '-Rate', rateExpr,
1482
+ ],
1483
+ { timeout: 25_000, windowsHide: true },
1484
+ (error) => {
1485
+ if (error) {
1486
+ cleanup()
1487
+ reject(error)
1488
+ return
1489
+ }
1490
+ try {
1491
+ // 读回 + 校验统一走 readWav:空音频(只有文件头)会在那里抛 emptyAudio,
1492
+ // 上层据此换一条能念中文的声线,绝不把 0 秒的段当成功
1493
+ const { buffer, duration } = readWav(wavPath)
1494
+ cleanup()
1495
+ resolve({
1496
+ audio: buffer,
1497
+ duration,
1498
+ sentenceBoundaries: [],
1499
+ mime: 'audio/wav',
1500
+ voiceName: voice ? voice.name : '',
1501
+ })
1502
+ } catch (readError) {
1503
+ cleanup()
1504
+ reject(readError)
1505
+ }
1506
+ },
1507
+ )
1508
+ })
1509
+
1510
+ /**
1511
+ * 语速换算:Edge 用百分比,say / espeak 用「词/分钟」。
1512
+ * 基线 180,'+5%' → 189,'-30%' → 126,夹在 80~420 之间防止离谱值。
1513
+ */
1514
+ const localWpm = () => {
1515
+ const edgeRate = Number(String(settings.rate || '0').replace('%', '')) || 0
1516
+ return Math.max(80, Math.min(420, Math.round(180 * (1 + edgeRate / 100))))
1517
+ }
1518
+
1519
+ /**
1520
+ * 读回后端刚写出的 WAV:offset 28 是 byteRate,data 从 44 起,三端共用这一套算法。
1521
+ * 只有一个文件头、没有音频数据时抛 emptyAudio —— 纯英文声线念中文就会这样,
1522
+ * 上层据此换一条中文声线重来,绝不静默丢一段。
1523
+ */
1524
+ const readWav = (wavPath) => {
1525
+ const buffer = fs.readFileSync(wavPath)
1526
+ const byteRate = buffer.length >= 44 ? buffer.readUInt32LE(28) : 0
1527
+ const duration = byteRate > 0 ? Math.max(0, buffer.length - 44) / byteRate : 0
1528
+ if (duration <= 0.05) {
1529
+ const error = new Error(`synthesis produced empty audio (bytes=${buffer.length})`)
1530
+ error.emptyAudio = true
1531
+ throw error
1532
+ }
1533
+ return { buffer, duration }
1534
+ }
1535
+
1536
+ /**
1537
+ * macOS:用系统自带的 say。
1538
+ *
1539
+ * 文本走 -f 从文件读——命令行里的中文引号和换行会被切碎。
1540
+ * 输出显式指定 RIFF WAVE + 16bit PCM:say 默认写 AIFF,浏览器 Audio 播不了。
1541
+ */
1542
+ const synthesizeLocalSay = (text, voice) => new Promise((resolve, reject) => {
1543
+ const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
1544
+ const txtPath = path.join(LOCAL_AUDIO_DIR, `say-${stamp}.txt`)
1545
+ const wavPath = path.join(LOCAL_AUDIO_DIR, `say-${stamp}.wav`)
1546
+ const cleanup = () => {
1547
+ try { fs.unlinkSync(txtPath) } catch { /* 已经不在就算了 */ }
1548
+ try { fs.unlinkSync(wavPath) } catch { /* 同上 */ }
1549
+ }
1550
+ try {
1551
+ fs.mkdirSync(LOCAL_AUDIO_DIR, { recursive: true })
1552
+ fs.writeFileSync(txtPath, text, 'utf8')
1553
+ } catch (error) {
1554
+ reject(error)
1555
+ return
1556
+ }
1557
+ const args = []
1558
+ // 没选中声线就不传 -v,交给系统默认,别把空字符串塞进参数
1559
+ if (voice && voice.name) args.push('-v', voice.name)
1560
+ args.push(
1561
+ '-r', String(localWpm()),
1562
+ '--file-format=WAVE', '--data-format=LEI16@22050',
1563
+ '-f', txtPath, '-o', wavPath,
1564
+ )
1565
+ execFile('say', args, { timeout: 25_000 }, (error) => {
1566
+ if (error) {
1567
+ cleanup()
1568
+ reject(error)
1569
+ return
1570
+ }
1571
+ try {
1572
+ const { buffer, duration } = readWav(wavPath)
1573
+ cleanup()
1574
+ resolve({
1575
+ audio: buffer,
1576
+ duration,
1577
+ sentenceBoundaries: [],
1578
+ mime: 'audio/wav',
1579
+ voiceName: voice && voice.name ? voice.name : 'say',
1580
+ })
1581
+ } catch (readError) {
1582
+ cleanup()
1583
+ reject(readError)
1584
+ }
1585
+ })
1586
+ })
1587
+
1588
+ /**
1589
+ * Linux:用 espeak-ng(没装就退回老版 espeak)。
1590
+ *
1591
+ * -v 认语言代码(cmn / zh),-s 是词/分钟,-w 直接写 WAV,-f 从文件读文本。
1592
+ * 音质是这批里最机械的,但它是唯一「装完就有声音、完全离线」的 Linux 方案。
1593
+ */
1594
+ const synthesizeLocalEspeak = (text, voice) => new Promise((resolve, reject) => {
1595
+ const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
1596
+ const txtPath = path.join(LOCAL_AUDIO_DIR, `esp-${stamp}.txt`)
1597
+ const wavPath = path.join(LOCAL_AUDIO_DIR, `esp-${stamp}.wav`)
1598
+ const cleanup = () => {
1599
+ try { fs.unlinkSync(txtPath) } catch { /* 已经不在就算了 */ }
1600
+ try { fs.unlinkSync(wavPath) } catch { /* 同上 */ }
1601
+ }
1602
+ try {
1603
+ fs.mkdirSync(LOCAL_AUDIO_DIR, { recursive: true })
1604
+ fs.writeFileSync(txtPath, text, 'utf8')
1605
+ } catch (error) {
1606
+ reject(error)
1607
+ return
1608
+ }
1609
+ const bin = espeakBinary || 'espeak-ng'
1610
+ const args = ['-w', wavPath, '-f', txtPath, '-s', String(localWpm())]
1611
+ if (voice && voice.id) args.push('-v', voice.id)
1612
+ execFile(bin, args, { timeout: 25_000 }, (error) => {
1613
+ if (error) {
1614
+ cleanup()
1615
+ reject(error)
1616
+ return
1617
+ }
1618
+ try {
1619
+ const { buffer, duration } = readWav(wavPath)
1620
+ cleanup()
1621
+ resolve({
1622
+ audio: buffer,
1623
+ duration,
1624
+ sentenceBoundaries: [],
1625
+ mime: 'audio/wav',
1626
+ voiceName: voice && voice.id ? voice.id : bin,
1627
+ })
1628
+ } catch (readError) {
1629
+ cleanup()
1630
+ reject(readError)
1631
+ }
1632
+ })
1633
+ })
1634
+
1635
+ /**
1636
+ * 本地合成总入口,按平台 + 声线所选后端分派。
1637
+ *
1638
+ * Windows:用户选的若是 SAPI 声线就直接走 SAPI;否则先试 WinRT(自然语音音质最好),
1639
+ * 失败再落 SAPI,两级都留日志——那是「这台机器实际走哪条路」的唯一证据。
1640
+ * macOS:say 一条路。Linux:espeak-ng 一条路。
1641
+ * 三端都不联网、不注册、不装运行库。
1642
+ */
1643
+ const synthesizeLocal = async (text) => {
1644
+ // 首次就念、清单还没探到:补探一次(用户可能刚装完语音包)
1645
+ if (localVoicesSnapshot.length === 0) {
1646
+ await refreshLocalVoices().catch(() => {})
1647
+ }
1648
+ const voice = pickLocalVoice(localVoicesSnapshot)
1649
+
1650
+ const run = async (target) => {
1651
+ if (IS_MAC) return synthesizeLocalSay(text, target)
1652
+ if (!IS_WIN) return synthesizeLocalEspeak(text, target)
1653
+ if (target && target.engine === 'sapi') {
1654
+ return synthesizeLocalSapi(text, target.name)
1655
+ }
1656
+ try {
1657
+ return await synthesizeLocalWinRT(text, target)
1658
+ } catch (error) {
1659
+ if (error?.emptyAudio) throw error
1660
+ log(`winrt→sapi fallback chars=${text.length} err=${String(error?.message ?? error).split('\n')[0]}`)
1661
+ return synthesizeLocalSapi(text, target ? target.name : LOCAL_VOICE)
1662
+ }
1663
+ }
1664
+
1665
+ try {
1666
+ return await run(voice)
1667
+ } catch (error) {
1668
+ // 空音频:多半是用户选了一条纯英文声线去念中文(SAPI 会吐一个只有文件头的空 WAV)。
1669
+ // 不静默丢这一段,换清单里的中文声线再合一遍;还是空就抛给合成泵去重试。
1670
+ if (error?.emptyAudio) {
1671
+ const zhVoice = localVoicesSnapshot.find((item) => RE_ZH_LANG.test(String(item.lang || '')))
1672
+ if (zhVoice && (voice === null || zhVoice.name !== voice.name)) {
1673
+ log(`本机声线 ${voice ? voice.name : '默认'} 念中文出空音频,改用 ${zhVoice.name} 重试`)
1674
+ return run(zhVoice)
1675
+ }
1676
+ log(`本机声线 ${voice ? voice.name : '默认'} 念中文出空音频,且清单里没有别的中文声线`)
1677
+ }
1678
+ throw error
1679
+ }
1680
+ }
1681
+
1682
+ /**
1683
+ * 本地兜底合成:调 Windows 自带的 System.Speech 写一个 WAV 出来,再读回内存。
1684
+ *
1685
+ * 为什么需要它:Edge 那边每段固定要 2.5~7 秒,还有约三分之一概率卡到 21.5 秒超时。
1686
+ * 实时路径虽然能把增量文本早早切好送过来,但全都堵在合成这一步上 ——
1687
+ * 实测用户按回车到听见第一声要 43~105 秒。本地这一段实测 28 字 282ms,
1688
+ * 是让「边生成边念」真正能听见的唯一办法。
1689
+ *
1690
+ * 代价是音质:Microsoft Huihui 是上一代机器合成,没有 Edge 神经声线自然。
1691
+ */
1692
+ const synthesizeLocalSapi = (text, voiceName = LOCAL_VOICE) => new Promise((resolve, reject) => {
1693
+ const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
1694
+ const wantVoice = String(voiceName || LOCAL_VOICE).trim() || LOCAL_VOICE
1695
+ let txtPath = ''
1696
+ let wavPath = ''
1697
+ try {
1698
+ fs.mkdirSync(LOCAL_AUDIO_DIR, { recursive: true })
1699
+ txtPath = path.join(LOCAL_AUDIO_DIR, `sapi-${stamp}.txt`)
1700
+ wavPath = path.join(LOCAL_AUDIO_DIR, `sapi-${stamp}.wav`)
1701
+ // 文本走文件传,别塞进命令行:中文引号和换行会把参数切碎
1702
+ fs.writeFileSync(txtPath, text, 'utf8')
1703
+ } catch (error) {
1704
+ reject(error)
1705
+ return
1706
+ }
1707
+ // SAPI 的 Rate 是 -10~10 整数;Edge 那边 '+10%' 大约相当于 Rate=2
1708
+ const edgeRate = Number(String(settings.rate || '0').replace('%', '')) || 0
1709
+ const sapiRate = Math.max(-10, Math.min(10, Math.round(edgeRate / 5)))
1710
+ const script = [
1711
+ 'Add-Type -AssemblyName System.Speech',
1712
+ '$s = New-Object System.Speech.Synthesis.SpeechSynthesizer',
1713
+ // 声线名走环境变量:用户装的声线可能带单引号,拼进脚本会把命令切断。
1714
+ // 名字不存在(被卸载了)也不让它抛:包一层 try,退回系统默认声线照样出声。
1715
+ 'try { $s.SelectVoice($env:LS_VOICE) } catch { }',
1716
+ `$s.Rate = ${sapiRate}`,
1717
+ '$s.SetOutputToWaveFile($env:LS_WAV)',
1718
+ '$body = Get-Content -LiteralPath $env:LS_TXT -Raw -Encoding utf8',
1719
+ 'if ($body) { $s.Speak($body) }',
1720
+ '$s.Dispose()',
1721
+ ].join('; ')
1722
+ execFile(
1723
+ 'powershell.exe',
1724
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
1725
+ {
1726
+ timeout: 25_000,
1727
+ windowsHide: true,
1728
+ env: { ...process.env, LS_WAV: wavPath, LS_TXT: txtPath, LS_VOICE: wantVoice },
1729
+ },
1730
+ (error) => {
1731
+ const cleanup = () => {
1732
+ try { fs.unlinkSync(txtPath) } catch { /* 已经不在就算了 */ }
1733
+ try { fs.unlinkSync(wavPath) } catch { /* 同上 */ }
1734
+ }
1735
+ if (error) {
1736
+ cleanup()
1737
+ reject(error)
1738
+ return
1739
+ }
1740
+ try {
1741
+ // readWav 在 data 段为空(例如纯英文声线念中文只吐 46 字节文件头)时抛
1742
+ // emptyAudio,交给 synthesizeLocal 换中文声线重来,不静默丢这一整段
1743
+ const { buffer, duration } = readWav(wavPath)
1744
+ cleanup()
1745
+ // 本地出的是 WAV,传回去时要让 /audio 路由按真实类型下发,
1746
+ // 否则浏览器拿到 Content-Type=audio/mpeg 的 WAV 有可能直接拒绝播
1747
+ resolve({ audio: buffer, duration, sentenceBoundaries: [], mime: 'audio/wav', voiceName: wantVoice })
1748
+ } catch (readError) {
1749
+ cleanup()
1750
+ reject(readError)
1751
+ }
1752
+ },
1753
+ )
1754
+ })
1755
+
1756
+ /**
1757
+ * 调用 Edge 合成一段;429/网络错走指数退避,别一段就把整轮播报拖死。
1758
+ * ttsEngine='local' 时整段走本地;其余('edge' / 老配置的 'auto')只走 Edge,
1759
+ * 失败就抛给合成泵——那一层会一直重试到成功,绝不丢这一段。
1760
+ */
1761
+ /**
1762
+ * 给一次合成套一条总时长硬上限。
1763
+ *
1764
+ * Edge 那个 timeout 只覆盖连接和首包,合成阶段卡住它管不了;超时就把这次当失败
1765
+ * 抛出去,由上面那两层重试接手。底层 socket 收不到取消信号,所以超时后调用方
1766
+ * 会把共享实例丢掉(engine = null),下一次重试新建一条干净的连接。
1767
+ */
1768
+ const withDeadline = (promise, ms) => new Promise((resolve, reject) => {
1769
+ let settled = false
1770
+ const timer = setTimeout(() => {
1771
+ if (settled) return
1772
+ settled = true
1773
+ const error = new Error(`合成总时长超限 watchdog timeout ${ms}ms`)
1774
+ error.deadline = true
1775
+ reject(error)
1776
+ }, ms)
1777
+ promise.then(
1778
+ (value) => {
1779
+ if (settled) return
1780
+ settled = true
1781
+ clearTimeout(timer)
1782
+ resolve(value)
1783
+ },
1784
+ (error) => {
1785
+ if (settled) return
1786
+ settled = true
1787
+ clearTimeout(timer)
1788
+ reject(error)
1789
+ },
1790
+ )
1791
+ })
1792
+
1099
1793
  const synthesize = async (text, voice) => {
1794
+ if (settings.ttsEngine === 'local') return synthesizeLocal(text)
1795
+ // 线上档就是纯线上:失败不换成本地声线。宁可这一段跳过去,
1796
+ // 也不能让同一篇里一半晓晓一半瑶瑶——那比少一句话难听得多。
1100
1797
  await ensureEngine()
1101
1798
  let last
1102
1799
  for (let attempt = 0; attempt < SYNTH_RETRIES; attempt += 1) {
1103
1800
  try {
1104
- const result = await tts().generate(text, voice, {
1105
- rate: settings.rate,
1106
- volume: settings.volume,
1107
- outputFormat: engineModule.Constants.OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3,
1108
- })
1801
+ const result = await withDeadline(
1802
+ tts().generate(text, voice, {
1803
+ rate: settings.rate,
1804
+ volume: settings.volume,
1805
+ outputFormat: engineModule.Constants.OUTPUT_FORMAT.AUDIO_24KHZ_96KBITRATE_MONO_MP3,
1806
+ // 卡住的那次是 21.5 秒的 socket 超时;8 秒还没数据就放弃,交给上面的重试
1807
+ timeout: EDGE_TIMEOUT_MS,
1808
+ }),
1809
+ SYNTH_DEADLINE_MS,
1810
+ )
1109
1811
  if (!result?.audio || result.audio.length === 0) throw new Error('合成结果为空')
1110
1812
  return result
1111
1813
  } catch (error) {
1112
1814
  last = error
1815
+ // 卡死的那条 socket 已经不可信,丢掉实例让下一次重试重连
1816
+ if (error?.deadline) engine = null
1113
1817
  const status = Number(error?.status ?? 0)
1114
1818
  const message = String(error?.message ?? error)
1819
+ // 内层自救也要留痕:否则日志上只会看到一段莫名多等了二十秒
1820
+ log(`synth inner fail chars=${text.length} attempt=${attempt + 1} deadline=${error?.deadline === true} err=${message}`)
1115
1821
  const retryable = status === 429
1116
1822
  || status === 503
1117
1823
  || status === 0
@@ -1124,56 +1830,78 @@ export function apply(ctx, config = {}) {
1124
1830
  }
1125
1831
 
1126
1832
  /**
1127
- * 合成泵:两路流水线。
1833
+ * 合成泵:单路串行 + 只领先播报一段。
1834
+ *
1835
+ * 这里曾经是两路并行、一次性把整篇全合成放到 ready 里。它的问题不在"合成不够快",
1836
+ * 而在顺序:播放必须按 id 从小到大取,两路乱序完成时最小的 id 常常最后一个落地,
1837
+ * 后面早就合好的段也只能干等(日志实测干等 5.4 秒)。
1128
1838
  *
1129
- * 原来是一段接一段地串行合成,好处是完全不触发语音服务限流,坏处是——
1130
- * 只要有一段碰上 429 退避或握手变慢(实测有 20 秒的),后面所有段都跟着排队,
1131
- * 播放端就听到几秒甚至八秒的空白。两路可以让"慢的那一段"不再堵住别的段。
1132
- * 只开 MAX_SYNTH_PARALLEL = 2:再多,限流概率明显上升。
1839
+ * 现在改成"边播边合下一段"的流水线:合成第 n 段成功后立刻接着合第 n+1 段,
1840
+ * 但 ready 里最多留 MAX_SYNTH_LEAD 段。300 字播一分钟、合成只要 5~10 秒,
1841
+ * 余量足够,所以既不会断,也不会并发去撞 429。
1133
1842
  */
1134
1843
  const pump = async () => {
1135
1844
  if (working) return
1136
1845
  working = true
1137
1846
  bump()
1138
1847
  const worker = async () => {
1139
- while (jobs.length > 0) {
1848
+ // 只领先 MAX_SYNTH_LEAD 段:够下一段马上接上就行,再多只是白占内存。
1849
+ // 队列没被推满时会在这里停下,等客户端播完一段、/consume 回掉一段,再继续。
1850
+ while (jobs.length > 0 && ready.size < MAX_SYNTH_LEAD) {
1140
1851
  const job = jobs.shift()
1141
1852
  const text = job.text
1142
- // id 在取任务时就分配,保证 id 顺序 == 入队顺序(并发完成顺序可能乱)
1853
+ // id 在取任务时就分配,保证 id 顺序 == 入队顺序
1143
1854
  const id = nextId
1144
1855
  nextId += 1
1145
- try {
1146
- const voice = resolveVoiceFor(text)
1147
- const result = await synthesize(text, voice)
1148
- const item = {
1149
- id,
1150
- text,
1151
- index: job.index,
1152
- total: job.total,
1153
- chars: text.length,
1154
- voice,
1155
- // preview 是给客户端卡片当歌词看的,42 字太短会半句截断;
1156
- // 放宽到 120 字,即使用 3 行小字号也放得下,整段播报不会被省略号切掉
1157
- preview: text.length > 120 ? `${text.slice(0, 120)}…` : text,
1158
- // LRC 时间轴:块内每句的起点/时长(秒),客户端按 currentTime 找当前句
1159
- cues: buildCues(job.lines || splitLyricLines(text), result),
1160
- buffer: Buffer.isBuffer(result.audio) ? result.audio : Buffer.from(result.audio),
1161
- duration: Number(result.duration) || 0,
1162
- chime: job.chime === true,
1163
- at: Date.now(),
1856
+ // 内容完整性优先:合不出来就一直等它合出来,绝不丢一段。
1857
+ // 唯一的中断条件是新一轮请求把 epoch 推走——那时整篇本来就不该再念了。
1858
+ const myEpoch = epoch
1859
+ let attempt = 0
1860
+ while (true) {
1861
+ if (epoch !== myEpoch) break
1862
+ try {
1863
+ const voice = resolveVoiceFor(text)
1864
+ const result = await synthesize(text, voice)
1865
+ const item = {
1866
+ id,
1867
+ text,
1868
+ index: job.index,
1869
+ total: job.total,
1870
+ chars: text.length,
1871
+ voice,
1872
+ // preview 是给客户端卡片当歌词看的,42 字太短会半句截断;
1873
+ // 放宽到 120 字,即使用 3 行小字号也放得下,整段播报不会被省略号切掉
1874
+ preview: text.length > 120 ? `${text.slice(0, 120)}…` : text,
1875
+ // LRC 时间轴:块内每句的起点/时长(秒),客户端按 currentTime 找当前句
1876
+ cues: buildCues(job.lines || splitLyricLines(text), result),
1877
+ buffer: Buffer.isBuffer(result.audio) ? result.audio : Buffer.from(result.audio),
1878
+ duration: Number(result.duration) || 0,
1879
+ // Edge 回来的是 MP3、本地兜底回来的是 WAV,路由按这个下发 Content-Type
1880
+ mime: result.mime === 'audio/wav' ? 'audio/wav' : 'audio/mpeg',
1881
+ chime: job.chime === true,
1882
+ at: Date.now(),
1883
+ }
1884
+ ready.set(item.id, item)
1885
+ lastError = ''
1886
+ synthFailCount = 0
1887
+ trimReady()
1888
+ // 日志里的声线名要写「真正发声的那条」:本地档是系统声线(瑶瑶/Huihui),
1889
+ // 不是 Edge 的 id,否则又会造出「显示晓晓、实际念瑶瑶」那种假记录
1890
+ log(`synth ok id=${item.id} voice=${result.voiceName || item.voice} chars=${item.chars} bytes=${item.buffer.length} dur=${item.duration}`)
1891
+ bump()
1892
+ break
1893
+ } catch (error) {
1894
+ attempt += 1
1895
+ synthFailCount += 1
1896
+ lastError = String(error?.message ?? error).split('\n')[0]
1897
+ log(`synth fail id=${id} chars=${text.length} attempt=${attempt} err=${lastError}`)
1898
+ bump()
1899
+ if (epoch !== myEpoch) break
1900
+ // 退避翻倍、封顶 15 秒:Edge 恢复后这一段一定能补上,只是可能要等一会儿
1901
+ const backoff = Math.min(SYNTH_RETRY_BASE_MS * 2 ** Math.min(attempt - 1, 4), SYNTH_RETRY_MAX_MS)
1902
+ log(`synth retry id=${id} attempt=${attempt} retry_in=${backoff}ms`)
1903
+ await sleep(backoff)
1164
1904
  }
1165
- ready.set(item.id, item)
1166
- lastError = ''
1167
- synthFailCount = 0
1168
- trimReady()
1169
- log(`synth ok id=${item.id} voice=${item.voice} chars=${item.chars} bytes=${item.buffer.length} dur=${item.duration}`)
1170
- bump()
1171
- } catch (error) {
1172
- synthFailCount += 1
1173
- lastError = String(error?.message ?? error).split('\n')[0]
1174
- log(`synth fail id=${id} chars=${text.length} err=${lastError}`)
1175
- // 单段失败只跳过这段,不影响后面
1176
- bump()
1177
1905
  }
1178
1906
  }
1179
1907
  }
@@ -1211,6 +1939,8 @@ export function apply(ctx, config = {}) {
1211
1939
  chime: chime === true && index === 0,
1212
1940
  })))
1213
1941
  log(`enqueue chunks=${total} chars=${chunks.reduce((s, c) => s + c.length, 0)} interrupt=${interrupt}`)
1942
+ // 每段的头尾各记一小截:排查"标题和正文连读"时,看的就是标题后面到底有没有省略号
1943
+ chunks.forEach((piece, i) => log(`enqueue seg ${i + 1}/${total} len=${piece.length} head=${JSON.stringify(piece.slice(0, 16))} tail=${JSON.stringify(piece.slice(-10))}`))
1214
1944
  void pump()
1215
1945
  return { chunks: chunks.length, chars: chunks.reduce((sum, piece) => sum + piece.length, 0) }
1216
1946
  }
@@ -1332,6 +2062,10 @@ export function apply(ctx, config = {}) {
1332
2062
  const nextText = nextSource === null ? '' : (nextSource.preview !== undefined
1333
2063
  ? nextSource.preview
1334
2064
  : (nextSource.text.length > 120 ? `${nextSource.text.slice(0, 120)}…` : nextSource.text))
2065
+ // 本机模式下面板和日志要显示真正发声的那条声线,否则写着"晓晓"、实际念的是瑶瑶
2066
+ const localDisplay = (localVoiceSnapshot?.name || LOCAL_VOICE)
2067
+ .replace(/^Microsoft\s+/i, '')
2068
+ .replace(/\s+Desktop$/i, '')
1335
2069
  return {
1336
2070
  ok: true,
1337
2071
  rev,
@@ -1348,9 +2082,11 @@ export function apply(ctx, config = {}) {
1348
2082
  })(),
1349
2083
  settings: {
1350
2084
  voice: settings.voice,
1351
- voiceLabel: voiceLabel(settings.voice),
1352
- voiceName: voiceName(settings.voice),
1353
- previewText: previewTextFor(settings.voice),
2085
+ voiceLabel: settings.ttsEngine === 'local' ? `${localDisplay}·本机` : voiceLabel(settings.voice),
2086
+ voiceName: settings.ttsEngine === 'local' ? localDisplay : voiceName(settings.voice),
2087
+ previewText: settings.ttsEngine === 'local'
2088
+ ? `你好,我是${localDisplay}。这是 dsh-live-speak 的试听,语速 ${settings.rate}。`
2089
+ : previewTextFor(settings.voice),
1354
2090
  rate: settings.rate,
1355
2091
  volume: settings.volume,
1356
2092
  targetChars: settings.targetChars,
@@ -1360,6 +2096,17 @@ export function apply(ctx, config = {}) {
1360
2096
  codeQuiet: settings.codeQuiet,
1361
2097
  dockExpanded: settings.dockExpanded,
1362
2098
  dock: settings.dock,
2099
+ eq: settings.eq,
2100
+ ttsEngine: settings.ttsEngine,
2101
+ /** 本机声线清单(三端统一结构:name / id / lang / engine),空数组=还没探到。 */
2102
+ localVoices: localVoicesSnapshot,
2103
+ /** 当前实际发声的那条(用户没选时是自动挑出来的),设置页面下拉就选它。 */
2104
+ localVoice: localVoiceSnapshot ? localVoiceSnapshot.name : '',
2105
+ /** 用户自己选过的那条,空=让它自动挑。 */
2106
+ localVoiceSelected: settings.localVoice,
2107
+ /** 实际走的本机后端:winrt / sapi / say / espeak。 */
2108
+ localVoiceEngine: localVoiceSnapshot ? localVoiceSnapshot.engine : '',
2109
+ hasNaturalLocalVoice: hasNaturalLocalVoice(localVoicesSnapshot),
1363
2110
  },
1364
2111
  /** 设置面板的声线下拉用这份清单,避免客户端自己维护一份。 */
1365
2112
  voices: VOICES,
@@ -1773,7 +2520,7 @@ export function apply(ctx, config = {}) {
1773
2520
  }
1774
2521
  const total = item.buffer.length
1775
2522
  const range = String(req.headers.range ?? '')
1776
- res.setHeader('Content-Type', 'audio/mpeg')
2523
+ res.setHeader('Content-Type', item.mime === 'audio/wav' ? 'audio/wav' : 'audio/mpeg')
1777
2524
  res.setHeader('Accept-Ranges', 'bytes')
1778
2525
  res.setHeader('Cache-Control', 'no-store')
1779
2526
  // 支持单区间 Range:Chromium 的 <audio> 会先要一小段探测时长
@@ -1840,10 +2587,13 @@ export function apply(ctx, config = {}) {
1840
2587
  bump()
1841
2588
  return { payload: { ok: true, auto: settings.auto } }
1842
2589
  }),
1843
- // 页面播完一段,让宿主回收缓冲
2590
+ // 页面播完一段,让宿主回收缓冲,并把合成队列往前推一段
1844
2591
  post('/consume', (body) => {
1845
2592
  const id = Number(body?.id)
1846
- if (Number.isFinite(id) && ready.delete(id)) bump()
2593
+ if (Number.isFinite(id) && ready.delete(id)) {
2594
+ bump()
2595
+ void pump()
2596
+ }
1847
2597
  return { payload: { ok: true } }
1848
2598
  }),
1849
2599
  // 停止:清空还没播的
@@ -1860,10 +2610,21 @@ export function apply(ctx, config = {}) {
1860
2610
  const patch = body !== null && typeof body === 'object' ? body : {}
1861
2611
  const next = normalizeSettings({ ...settings, ...patch })
1862
2612
  Object.assign(settings, next)
2613
+ // 用户刚换了本机声线:立刻按新选择重算"当前发声的那条",
2614
+ // 否则设置页要等下一次探测(最多 5 分钟)才显示对
2615
+ localVoiceSnapshot = pickLocalVoice(localVoicesSnapshot)
1863
2616
  writeSettings(settings)
1864
2617
  bump()
1865
2618
  return { payload: { ok: true, settings: snapshot().settings } }
1866
2619
  }),
2620
+ // 重新检测本机声线:用户刚装完语音包时点设置页那个按钮,绕开 5 分钟缓存立刻重探
2621
+ post('/local-voices', () => refreshLocalVoices(true)
2622
+ .then(() => ({ payload: {
2623
+ ok: true,
2624
+ voices: localVoicesSnapshot,
2625
+ localVoice: localVoiceSnapshot ? localVoiceSnapshot.name : '',
2626
+ localVoiceEngine: localVoiceSnapshot ? localVoiceSnapshot.engine : '',
2627
+ } }))),
1867
2628
  // 界面里手动念一段;append 模式用于流式逐句追加(不打断正在念的)
1868
2629
  post('/speak', (body) => {
1869
2630
  const text = String(body?.text ?? '')
@@ -1957,7 +2718,7 @@ export function apply(ctx, config = {}) {
1957
2718
  for (const route of routes) {
1958
2719
  if (typeof route === 'function') disposers.push(route)
1959
2720
  }
1960
- log('routes registered: state, audio, auto, consume, clear, settings, speak, debug')
2721
+ log('routes registered: state, audio, auto, consume, clear, settings, local-voices, speak, debug')
1961
2722
  }
1962
2723
  } catch (error) {
1963
2724
  log(`routes failed: ${String(error?.message ?? error)}`)
@@ -1990,6 +2751,10 @@ export function apply(ctx, config = {}) {
1990
2751
  .then((mod) => log('edge-tts 依赖加载 OK', `EdgeTTS=${typeof mod?.EdgeTTS}`, `Constants=${typeof mod?.Constants}`))
1991
2752
  .catch((error) => log('edge-tts 依赖加载失败', String(error?.message ?? error)))
1992
2753
 
2754
+ // 同时探一次本机语音:设置页要在「只装了机器音」时给出装晓晓的引导,
2755
+ // 播报时也按探测结果挑最自然的那一个。
2756
+ void refreshLocalVoices()
2757
+
1993
2758
  return () => {
1994
2759
  for (const dispose of disposers) {
1995
2760
  try {