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