dsh-live-speak 1.0.1 → 1.0.3
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 +39 -2
- package/index.js +35 -6
- package/package.json +1 -1
package/RELEASE-NOTES.md
CHANGED
|
@@ -204,6 +204,45 @@ dsh-live-speak
|
|
|
204
204
|
|
|
205
205
|
---
|
|
206
206
|
|
|
207
|
+
## 1.0.3 更新说明
|
|
208
|
+
|
|
209
|
+
**修掉:Linux 上本机没装语音后端时,会一直无声地重试**
|
|
210
|
+
|
|
211
|
+
本地语音在 Linux 上用的是系统自带的 `espeak-ng`。如果这台机器根本没装,原来的行为是:每一段都判为合成失败,然后一直重试下去——声音始终不出来,日志每 15 秒还写一条,看起来像插件卡死了。
|
|
212
|
+
|
|
213
|
+
现在把「后端根本不存在」和「网络抖了一下」分成两类处理:
|
|
214
|
+
|
|
215
|
+
- **网络类失败**:照旧快速重试(0.9 秒起、封顶 15 秒),恢复后立刻补上,这是原来就有的能力
|
|
216
|
+
- **后端不存在**:重试间隔拉到 60 秒,日志收敛成每 10 次一条。它仍然不会跳过任何一段,而且装好 `espeak-ng` 后最多一分钟就会自己恢复出声,不需要重启 DSH
|
|
217
|
+
|
|
218
|
+
macOS 上如果系统被裁剪到连 `say` 都没有,走同样的处理。**Windows 用户不受影响**——本地档用的是系统语音,不经过这条路径。
|
|
219
|
+
|
|
220
|
+
**怎么验证的**:在 Windows 上以「免安装解包」的方式跑了真实的 `espeak-ng 1.52.0`(同一个程序、同一套命令行),验证了声线清单解析、中文合成、WAV 输出格式和读回时长四件事:223 行声线清单解析出 222 条全部正确,41 字中文合成出 9.52 秒、22050Hz/16bit 的标准 WAVE,读回时长计算一致。测试全程没有在系统里安装任何东西,临时文件已清理。
|
|
221
|
+
|
|
222
|
+
**一条不变的原则**:合成失败不跳过。任何一段合成不出来,就一直重试到成功为止,宁可晚几秒开口,也不让内容缺一段。
|
|
223
|
+
|
|
224
|
+
当前版本:**1.0.3**
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## 1.0.2 更新说明
|
|
229
|
+
|
|
230
|
+
**修掉:标题和正文连成一句**
|
|
231
|
+
|
|
232
|
+
一篇带小标题的文章,原来会念成「帘卷西风济南的趵突泉边,如今游人如织」——标题和正文粘在一起,一口气读下来。
|
|
233
|
+
|
|
234
|
+
根因不在标点,而在清洗阶段:为了去掉替换文件名后留下的空格,有一处正则用了「任意空白」,而这个「空白」把段落之间的**换行也算在内**。于是单独一行的标题被并进下一段,连成一句话,后面「短行补标点」的处理也就永远轮不到它。
|
|
235
|
+
|
|
236
|
+
现在:
|
|
237
|
+
|
|
238
|
+
- 只抹掉同一行里的空格,换行原样保留,标题重新独立成行
|
|
239
|
+
- 标题行末尾补的是**句号**,不是省略号——实测下来省略号几乎不产生停顿,句号才有一次真正的断句
|
|
240
|
+
- 顺带修好同类问题:列表项(`- 写周报` 这种)和短的独立行,现在各自成句,不再粘成一片
|
|
241
|
+
|
|
242
|
+
**验证**:真实播报一篇 246 字、带一个短行标题的文章,切成 2 段,标题被读成「帘卷西风。」后接正文;两段合成零失败、零重试,播放间隙 2 毫秒,全程无跳段。
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
207
246
|
## 1.0.1 更新说明
|
|
208
247
|
|
|
209
248
|
**新增:本地语音档,可选可换**
|
|
@@ -224,5 +263,3 @@ dsh-live-speak
|
|
|
224
263
|
- 本地合成若返回空音频(只有文件头、时长 0),原来会被当成成功、播放 0 秒等于丢段——现在会检测出来并换声线重试
|
|
225
264
|
|
|
226
265
|
**一条不变的原则**:合成失败不跳过。任何一段合成不出来,就一直重试到成功为止,宁可晚几秒开口,也不让内容缺一段。
|
|
227
|
-
|
|
228
|
-
当前版本:**1.0.1**
|
package/index.js
CHANGED
|
@@ -452,7 +452,9 @@ function cleanForSpeech(raw) {
|
|
|
452
452
|
text = text.replace(/`([^`]*)`/gu, '$1')
|
|
453
453
|
// 标题行不补句号,改补省略号:句号只给 200 毫秒,听起来还是"帘卷西风济南的趵突泉边"一句;
|
|
454
454
|
// 省略号的停顿明显长一截,标题和正文才听得出是两件事。
|
|
455
|
-
|
|
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}。`))
|
|
456
458
|
text = text.replace(/^\s{0,3}>\s?/gmu, '')
|
|
457
459
|
text = text.replace(/^\s{0,3}[-*+]\s+/gmu, '')
|
|
458
460
|
text = text.replace(/^\s{0,3}\d+[.)]\s+/gmu, '')
|
|
@@ -469,8 +471,10 @@ function cleanForSpeech(raw) {
|
|
|
469
471
|
// 收尾:压缩空白,逐行补停顿标点(换行是分段的天然边界)
|
|
470
472
|
text = text.replace(/[ \t\u00A0]+/gu, ' ')
|
|
471
473
|
text = text.replace(/\s+([。!?!?…;;,,])/gu, '$1')
|
|
472
|
-
// 替换文件名后会在中文之间留一个空格("已保存为 网页"),TTS
|
|
473
|
-
|
|
474
|
+
// 替换文件名后会在中文之间留一个空格("已保存为 网页"),TTS 会读成停顿,顺手抹掉。
|
|
475
|
+
// 只能抹行内空白:换成 \s 会把换行一起吃掉,把单独一行的标题粘进下一段正文
|
|
476
|
+
// ("老书店的下午" + 换行 + "街角那家……" 变成一整句),短行补省略号的分支就永远走不到。
|
|
477
|
+
text = text.replace(/([\u4E00-\u9FFF])[ \t\u00A0]+(?=[\u4E00-\u9FFF])/gu, '$1')
|
|
474
478
|
const lines = text
|
|
475
479
|
.split('\n')
|
|
476
480
|
.map((line) => line.trim())
|
|
@@ -479,8 +483,9 @@ function cleanForSpeech(raw) {
|
|
|
479
483
|
text = lines
|
|
480
484
|
.map((line) => {
|
|
481
485
|
if (/[。!?!?…;;::,,]$/u.test(line)) return line
|
|
482
|
-
// 短、没逗号的一行,基本就是没写成 Markdown
|
|
483
|
-
|
|
486
|
+
// 短、没逗号的一行,基本就是没写成 Markdown 的小标题;长句是正文。
|
|
487
|
+
// 两者都补句号——省略号在 Edge 的声线里几乎没有停顿(实测比无标点还短),
|
|
488
|
+
// 只有句号才有断句,标题和正文才听得出是两件事。
|
|
484
489
|
return `${line}。`
|
|
485
490
|
})
|
|
486
491
|
.join('\n')
|
|
@@ -1565,6 +1570,12 @@ export function apply(ctx, config = {}) {
|
|
|
1565
1570
|
execFile('say', args, { timeout: 25_000 }, (error) => {
|
|
1566
1571
|
if (error) {
|
|
1567
1572
|
cleanup()
|
|
1573
|
+
// say 是系统自带,正常不会缺;被裁剪的发行版或沙盒里可能没有。
|
|
1574
|
+
// 这是确定性失败:重试多少次都不会出声,标成 fatal 让合成泵放慢重试。
|
|
1575
|
+
if (error.code === 'ENOENT' || error.code === 'EACCES') {
|
|
1576
|
+
error.fatal = true
|
|
1577
|
+
error.message = `local say unavailable (${error.code})`
|
|
1578
|
+
}
|
|
1568
1579
|
reject(error)
|
|
1569
1580
|
return
|
|
1570
1581
|
}
|
|
@@ -1612,6 +1623,13 @@ export function apply(ctx, config = {}) {
|
|
|
1612
1623
|
execFile(bin, args, { timeout: 25_000 }, (error) => {
|
|
1613
1624
|
if (error) {
|
|
1614
1625
|
cleanup()
|
|
1626
|
+
// 二进制根本不在(Linux 上没装 espeak-ng / espeak):确定性失败。
|
|
1627
|
+
// 重试一万次也不会出声,标成 fatal 让合成泵把重试间隔拉到 60 秒,
|
|
1628
|
+
// 而不是每 15 秒刷一条日志、整篇却一直静默。
|
|
1629
|
+
if (error.code === 'ENOENT' || error.code === 'EACCES') {
|
|
1630
|
+
error.fatal = true
|
|
1631
|
+
error.message = `local espeak binary unavailable: ${bin} (${error.code})`
|
|
1632
|
+
}
|
|
1615
1633
|
reject(error)
|
|
1616
1634
|
return
|
|
1617
1635
|
}
|
|
@@ -1894,9 +1912,20 @@ export function apply(ctx, config = {}) {
|
|
|
1894
1912
|
attempt += 1
|
|
1895
1913
|
synthFailCount += 1
|
|
1896
1914
|
lastError = String(error?.message ?? error).split('\n')[0]
|
|
1897
|
-
log(`synth fail id=${id} chars=${text.length} attempt=${attempt} err=${lastError}`)
|
|
1898
1915
|
bump()
|
|
1899
1916
|
if (epoch !== myEpoch) break
|
|
1917
|
+
// 后端二进制不存在(Linux 没装 espeak-ng,或 mac 上的 say 被裁掉)属于确定性失败:
|
|
1918
|
+
// 重试一万次也不会出声,但这一段也不能丢(内容完整性优先)。
|
|
1919
|
+
// 所以照样一直重试——用户装上后端、或切回线上档(epoch 变化)就自动恢复——
|
|
1920
|
+
// 只把间隔拉到 60 秒、日志每 10 次才报一条,避免整篇静默的同时还在刷屏。
|
|
1921
|
+
if (error?.fatal) {
|
|
1922
|
+
if (attempt % 10 === 1) {
|
|
1923
|
+
log(`local backend unavailable id=${id} attempt=${attempt} err=${lastError} retry_in=60s`)
|
|
1924
|
+
}
|
|
1925
|
+
await sleep(60_000)
|
|
1926
|
+
continue
|
|
1927
|
+
}
|
|
1928
|
+
log(`synth fail id=${id} chars=${text.length} attempt=${attempt} err=${lastError}`)
|
|
1900
1929
|
// 退避翻倍、封顶 15 秒:Edge 恢复后这一段一定能补上,只是可能要等一会儿
|
|
1901
1930
|
const backoff = Math.min(SYNTH_RETRY_BASE_MS * 2 ** Math.min(attempt - 1, 4), SYNTH_RETRY_MAX_MS)
|
|
1902
1931
|
log(`synth retry id=${id} attempt=${attempt} retry_in=${backoff}ms`)
|