dsh-chubby-whale 1.1.64 → 1.1.65
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/CHANGELOG.md +26 -0
- package/dist/client.js +23 -10
- package/dist/host/http.js +95 -5
- package/dist/host/routes/asr.js +89 -2
- package/dist/host/routes/tts.js +35 -10
- package/dist/host/voice/cloud-tts.js +12 -1
- package/dist/shared/voices.js +62 -0
- package/docs//345/217/221/345/270/203/350/247/204/350/214/203.md +5 -5
- package/docs//345/276/205/351/207/215/345/220/257/347/224/237/346/225/210.md +11 -0
- package/docs//346/250/241/345/235/227/346/216/245/345/217/243/346/270/205/345/215/225.md +3 -2
- package/docs//350/267/250/345/271/263/345/217/260/344/270/216/345/210/206/345/267/245.md +3 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,32 @@
|
|
|
4
4
|
|
|
5
5
|
## [未发布]
|
|
6
6
|
|
|
7
|
+
## [1.1.65] - 2026-11-07
|
|
8
|
+
|
|
9
|
+
### 修复(B93 音色白名单同源分身)
|
|
10
|
+
- **音色白名单只在客户端**:宿主 `/tts` 只做 `^[a-zA-Z0-9-]+# 更新日志
|
|
11
|
+
|
|
12
|
+
本文件格式遵循 Keep a Changelog,版本号遵循语义化版本。版本号唯一来源是 `package.json`;「未发布」一节记录已进 `main`、尚未发版的内容,发版时整节改为 `## [X.Y.Z] - YYYY-MM-DD`。
|
|
13
|
+
|
|
14
|
+
,HTTP 直调 `ja-JP-NanamiNeural` 会原样进引擎(旧测试里甚至写着「现状快照:仍会收到它」)。白名单提到 `shared/voices.ts`(client/host 同一份),名单外**回落默认音色**(不 400,兼容旧调用方)并带 `x-tts-voice: fallback`
|
|
15
|
+
- **路由内自建引擎兜底**:`opts.engine || createEdgeTtsEngine()` 与「引擎必须在装配层构造(否则没句柄 dispose)」冲突;改为 engine 必传,构造时缺即抛(fail-fast)
|
|
16
|
+
- `ports.ts` 死字段 `voice?: string` 删除
|
|
17
|
+
|
|
18
|
+
### 修复(B93 ASR 断连中止分身)
|
|
19
|
+
- **客户端已放弃,宿主还在跑满 60s**:`/asr` 没有断连监听,客户端 30s 超时并重试后,宿主仍把第一次识别跑完(付费调用)
|
|
20
|
+
- 现在 `watchClientGone` 三条判定(`req aborted` / `req close` 且未收完 / `res close` 且未写完)——**注意不能只看 close**:正常请求 body 读完就发 close,直接判会误伤每一个正常请求
|
|
21
|
+
- 断开即 abort 上游;`AbortError` 按「客户端走了」处理:只留 `[asr] ASR-CLIENT-ABORT` 诊断,**不写缓存、不计失败、不回包**
|
|
22
|
+
- `host/http.ts` 的 `fetchJson/fetchWithTimeout` 支持外部 `signal`(原来会被内部超时**覆盖**掉),与超时用 `AbortSignal.any` 组合
|
|
23
|
+
|
|
24
|
+
### 修复(主代理)
|
|
25
|
+
- **云端日预算并发超额**:判定与记账之间有 `await fetch`,并发 N 个请求一起通过判定(最多超额 `(N-1)×单次字数`)。改为发请求前**预占**、`finally` 释放,`usage()` 仍只报已用量
|
|
26
|
+
- **朗读队列丢段无人结账**:`capQueue` 只返回数量,被丢段若带 `onDone` 永远没人调(叙述锁只能等时间窗自愈);新增 `capQueueDetailed` 返回被丢段,`voice.ts` 补 `onDone` 并清预取引用
|
|
27
|
+
- **云端谎报 `x-tts-voice: fallback`**:云端用配置里的 speaker、不接收 `voice`,成功出云端时摘掉该头
|
|
28
|
+
|
|
29
|
+
### 测试
|
|
30
|
+
- 新增 `test-voices-shared.mjs`(60)、`test-asr-abort.mjs`(46),接入 `npm test`;`test-persona-cloud` +3、`test-voice-queue` +4、`test-voice-routes` +2
|
|
31
|
+
- 全链 **3914 项 0 失败**(`node tools/test-count.mjs`:54 个套件,失败 0,未识别 0)
|
|
32
|
+
|
|
7
33
|
## [1.1.64] - 2026-11-07
|
|
8
34
|
|
|
9
35
|
### 修复(B92 客户端朗读播放路径分身)
|
package/dist/client.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
// src/client/version.ts
|
|
28
|
-
var VERSION = "1.1.
|
|
28
|
+
var VERSION = "1.1.65";
|
|
29
29
|
|
|
30
30
|
// src/client/styles.ts
|
|
31
31
|
var W = 240;
|
|
@@ -1337,7 +1337,7 @@
|
|
|
1337
1337
|
}
|
|
1338
1338
|
}
|
|
1339
1339
|
|
|
1340
|
-
// src/
|
|
1340
|
+
// src/shared/voices.ts
|
|
1341
1341
|
var TTS_VOICES = [
|
|
1342
1342
|
"zh-CN-XiaoyiNeural",
|
|
1343
1343
|
"zh-CN-XiaoxiaoNeural",
|
|
@@ -1359,14 +1359,16 @@
|
|
|
1359
1359
|
function normalizeVoiceName(name, fallback = DEFAULT_VOICE) {
|
|
1360
1360
|
let n = "";
|
|
1361
1361
|
try {
|
|
1362
|
-
n =
|
|
1363
|
-
} catch
|
|
1362
|
+
n = (name == null ? "" : String(name)).trim();
|
|
1363
|
+
} catch {
|
|
1364
1364
|
return fallback;
|
|
1365
1365
|
}
|
|
1366
1366
|
if (!n) return fallback;
|
|
1367
1367
|
const hit = VOICE_BY_KEY.get(n.toLowerCase());
|
|
1368
1368
|
return hit || fallback;
|
|
1369
1369
|
}
|
|
1370
|
+
|
|
1371
|
+
// src/client/voice/voices.ts
|
|
1370
1372
|
function readStoredVoiceName() {
|
|
1371
1373
|
try {
|
|
1372
1374
|
return normalizeVoiceName(localStorage.getItem("chubby-tts-voice"));
|
|
@@ -1485,12 +1487,11 @@
|
|
|
1485
1487
|
return typeof token === "number" && token !== current;
|
|
1486
1488
|
}
|
|
1487
1489
|
var TTS_QUEUE_MAX = 200;
|
|
1488
|
-
function
|
|
1490
|
+
function capQueueDetailed(q, max = TTS_QUEUE_MAX) {
|
|
1489
1491
|
const limit = Math.max(0, Math.floor(Number(max) || 0));
|
|
1490
|
-
if (!Array.isArray(q) || q.length <= limit) return
|
|
1492
|
+
if (!Array.isArray(q) || q.length <= limit) return [];
|
|
1491
1493
|
const drop = q.length - limit;
|
|
1492
|
-
q.splice(0, drop);
|
|
1493
|
-
return drop;
|
|
1494
|
+
return q.splice(0, drop);
|
|
1494
1495
|
}
|
|
1495
1496
|
|
|
1496
1497
|
// src/client/voice/recorder.ts
|
|
@@ -2558,8 +2559,20 @@
|
|
|
2558
2559
|
});
|
|
2559
2560
|
ttsSeq += segs.length;
|
|
2560
2561
|
for (let i = 0; i < jobs.length; i++) ttsQ.push(jobs[i]);
|
|
2561
|
-
const
|
|
2562
|
-
if (
|
|
2562
|
+
const droppedJobs = capQueueDetailed(ttsQ);
|
|
2563
|
+
if (droppedJobs.length > 0) {
|
|
2564
|
+
stats.qDropped = (stats.qDropped || 0) + droppedJobs.length;
|
|
2565
|
+
for (const d of droppedJobs) {
|
|
2566
|
+
try {
|
|
2567
|
+
ttsPrefetch.delete(d.seq);
|
|
2568
|
+
} catch (e) {
|
|
2569
|
+
}
|
|
2570
|
+
try {
|
|
2571
|
+
if (d && typeof d.onDone === "function") d.onDone();
|
|
2572
|
+
} catch (e) {
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2563
2576
|
prefetchUpcoming(!!(ttsQ[0] && ttsQ[0].narration));
|
|
2564
2577
|
pumpTts();
|
|
2565
2578
|
}
|
package/dist/host/http.js
CHANGED
|
@@ -30,16 +30,106 @@ export function isTimeoutError(err) {
|
|
|
30
30
|
export function timeoutSignal(ms) {
|
|
31
31
|
return AbortSignal.timeout(ms);
|
|
32
32
|
}
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
/** 调用方给的 signal 两种写法都认(第 4 个参数 / init.signal);不是合法 AbortSignal 就当没给 */
|
|
34
|
+
function pickSignal(signal, init) {
|
|
35
|
+
const cand = signal || (init && init.signal);
|
|
36
|
+
if (!cand)
|
|
37
|
+
return undefined;
|
|
38
|
+
return typeof cand.addEventListener === 'function' && typeof cand.aborted === 'boolean' ? cand : undefined;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 把「内部超时」和「调用方中止」(客户端断连等外部 signal)合成一个 signal:任一触发都中止外呼。
|
|
42
|
+
*
|
|
43
|
+
* 为什么要合:只用内部超时的话,调用方已经放弃了(客户端 30s 硬超时、切页面、断网),
|
|
44
|
+
* 宿主仍会把付费外呼跑满 ASR_TIMEOUT=60s —— 客户端早就超时重发了一次,等于同一句话付两次钱
|
|
45
|
+
* (2026-11-07 B93 取证)。
|
|
46
|
+
*
|
|
47
|
+
* 两条路:
|
|
48
|
+
* · 有 AbortSignal.any(Node ≥20.3)就直接用:内部按弱引用管理源 signal 的监听,不会在
|
|
49
|
+
* 长期存活的 signal 上留监听;由此派生的 signal 被 fetch 直接使用;
|
|
50
|
+
* · 老 Node 没有 any 时手写组合:把**先触发那一方**的 reason 原样转发(超时仍是 TimeoutError、
|
|
51
|
+
* 外部中止仍是 AbortError,isTimeoutError 的口径不变)。
|
|
52
|
+
* 监听怎么收尾(防重复监听/泄漏):外部 signal 上的转发监听在「触发」或「本次外呼结束」时
|
|
53
|
+
* 立刻摘掉(dispose);超时 signal 上的监听刻意保留到它到点 —— 那个 signal 是本次调用自己的,
|
|
54
|
+
* 而且超时必须继续盖住响应体读取(fetch resolve 之后才算开始读 body)。
|
|
55
|
+
* 代价:老 Node 上「外部中止」只覆盖到响应头返回为止(之后的 body 读取只受内部超时约束);
|
|
56
|
+
* Node ≥20.3 走 any 路径没有这个差别。
|
|
57
|
+
*/
|
|
58
|
+
function combineSignals(timeout, outer) {
|
|
59
|
+
// 没有外部 signal:原样交回超时 signal —— 与改动前完全一致(错误仍是 TimeoutError)
|
|
60
|
+
if (!outer)
|
|
61
|
+
return { signal: timeout, dispose() { } };
|
|
62
|
+
const AnySig = AbortSignal;
|
|
63
|
+
if (typeof AnySig.any === 'function') {
|
|
64
|
+
try {
|
|
65
|
+
return { signal: AnySig.any([timeout, outer]), dispose() { } };
|
|
66
|
+
}
|
|
67
|
+
catch { }
|
|
68
|
+
}
|
|
69
|
+
const ctl = new AbortController();
|
|
70
|
+
let hooked = false;
|
|
71
|
+
/** 只摘外部 signal 上的转发监听(超时那份要留到到点,body 读取还要靠它兜底) */
|
|
72
|
+
function unhookOuter() {
|
|
73
|
+
if (!hooked)
|
|
74
|
+
return;
|
|
75
|
+
hooked = false;
|
|
76
|
+
try {
|
|
77
|
+
outer.removeEventListener('abort', onAbort);
|
|
78
|
+
}
|
|
79
|
+
catch { }
|
|
80
|
+
}
|
|
81
|
+
function onAbort(ev) {
|
|
82
|
+
unhookOuter();
|
|
83
|
+
const src = ev && (ev.target || ev.currentTarget);
|
|
84
|
+
try {
|
|
85
|
+
if (src && src.reason !== undefined)
|
|
86
|
+
ctl.abort(src.reason);
|
|
87
|
+
else
|
|
88
|
+
ctl.abort();
|
|
89
|
+
}
|
|
90
|
+
catch { }
|
|
91
|
+
}
|
|
92
|
+
// 传进来就已经中止的 signal 不会再发事件:先各自判一次,否则这个请求会一直等超时
|
|
93
|
+
if (timeout.aborted || outer.aborted) {
|
|
94
|
+
const src = timeout.aborted ? timeout : outer;
|
|
95
|
+
try {
|
|
96
|
+
ctl.abort(src.reason);
|
|
97
|
+
}
|
|
98
|
+
catch { }
|
|
99
|
+
return { signal: ctl.signal, dispose() { } };
|
|
100
|
+
}
|
|
101
|
+
hooked = true;
|
|
102
|
+
try {
|
|
103
|
+
timeout.addEventListener('abort', onAbort);
|
|
104
|
+
}
|
|
105
|
+
catch { }
|
|
106
|
+
try {
|
|
107
|
+
outer.addEventListener('abort', onAbort);
|
|
108
|
+
}
|
|
109
|
+
catch { }
|
|
110
|
+
return { signal: ctl.signal, dispose: unhookOuter };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* 带超时的 fetch(薄封装:超时拒绝时抛 TimeoutError)。
|
|
114
|
+
* 可选第 4 个参数 signal(也认 init.signal):调用方中止时立刻中断,错误名为 AbortError;
|
|
115
|
+
* 不传时行为与改动前一致。
|
|
116
|
+
*/
|
|
117
|
+
export async function fetchWithTimeout(url, init = {}, ms = 30000, signal) {
|
|
118
|
+
const combo = combineSignals(timeoutSignal(ms), pickSignal(signal, init));
|
|
119
|
+
try {
|
|
120
|
+
return await fetch(url, { ...(init || {}), signal: combo.signal });
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
combo.dispose();
|
|
124
|
+
}
|
|
36
125
|
}
|
|
37
126
|
/**
|
|
38
127
|
* 带超时的 fetch + 一次性读文本(能解析就顺手给 JSON)。
|
|
39
128
|
* 不因 HTTP 非 2xx 抛错——路由要自己拿 status 决定回什么文案。
|
|
129
|
+
* signal:可选,调用方(如客户端断连)中止时立刻中断外呼。
|
|
40
130
|
*/
|
|
41
|
-
export async function fetchJson(url, init = {}, ms = 30000) {
|
|
42
|
-
const resp = await fetchWithTimeout(url, init, ms);
|
|
131
|
+
export async function fetchJson(url, init = {}, ms = 30000, signal) {
|
|
132
|
+
const resp = await fetchWithTimeout(url, init, ms, signal);
|
|
43
133
|
const text = await resp.text().catch(() => '');
|
|
44
134
|
let data = null;
|
|
45
135
|
try {
|
package/dist/host/routes/asr.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* POST JSON { audio: base64, format: 'webm'|'wav'|'mp3' } → { text }
|
|
4
4
|
* Key 由入口注入(getKey),路由不再自己读 config.json / 环境变量。
|
|
5
5
|
* 体积:请求体(base64)上限 12MB → 413;解码后音频上限 8MB → 同样 413(口径统一,2026-11-07)
|
|
6
|
-
* 外呼超时 60
|
|
6
|
+
* 外呼超时 60 秒;**客户端断开(30s 硬超时/切页/断网)立刻中止外呼**,不再把付费识别跑满 60s(B93)
|
|
7
7
|
*/
|
|
8
8
|
import { hashBytes } from '../../shared/hash.js';
|
|
9
9
|
import { isInterruptWord, isNoiseText } from '../../shared/noise.js';
|
|
@@ -20,8 +20,76 @@ const ASR_MAX_BODY = 12 * 1024 * 1024;
|
|
|
20
20
|
const ASR_MAX_AUDIO = 8 * 1024 * 1024;
|
|
21
21
|
/** 外呼超时:60 秒 */
|
|
22
22
|
const ASR_TIMEOUT = 60 * 1000;
|
|
23
|
+
/**
|
|
24
|
+
* 客户端已经走了的日志标签:与真正的上游超时/网络错误分开,排障时一眼能分清。
|
|
25
|
+
* 分两条是因为两种时机差一步:外呼发出后断开(中止外呼)/ 请求体还没读完就断开(根本没发起外呼)。
|
|
26
|
+
*/
|
|
27
|
+
const LOG_CLIENT_ABORT_OUTBOUND = '[asr] ASR-CLIENT-ABORT 客户端已断开,上游外呼已中止(不计入 ASR 失败)';
|
|
28
|
+
const LOG_CLIENT_ABORT_REQUEST = '[asr] ASR-CLIENT-ABORT 客户端已断开,请求中止(未发起外呼,不计入 ASR 失败)';
|
|
29
|
+
/**
|
|
30
|
+
* 盯住「客户端是不是走了」。返回的 signal 会被合进外呼超时(见 host/http.ts 的 fetchWithTimeout),
|
|
31
|
+
* 客户端一走,还在等上游的那次付费外呼就立刻中止。
|
|
32
|
+
*
|
|
33
|
+
* 为什么不能只听 req 的 close(Node 24 本机实测,时间线见 scripts/test-asr-abort.mjs):
|
|
34
|
+
* 请求体读完时 req 就会发 close(此时 req.complete=true、res.writableEnded=false、响应还没写)——
|
|
35
|
+
* 只看 close 会把**每个正常请求**都误判成断连。真正的断连有三种形状:
|
|
36
|
+
* ① req 'aborted':请求体还没收完客户端就断了(Node 24 实测会发,虽已标记废弃);
|
|
37
|
+
* ② req 'close' 且 req.complete === false:①的兜底(老版本/某些客户端不发 aborted);
|
|
38
|
+
* ③ res 'close' 且响应没写完(writableEnded 与 writableFinished 都为 false):客户端发完了
|
|
39
|
+
* body、等不到结果先走了 —— 客户端 30s 硬超时就是这个形状(宿主还在跑 60s 的那次)。
|
|
40
|
+
* 正常结束的 res 'close' 里 writableFinished=true,不会命中 ③,所以不会误中止正常请求。
|
|
41
|
+
*/
|
|
42
|
+
function watchClientGone(req, res) {
|
|
43
|
+
const ctl = new AbortController();
|
|
44
|
+
let gone = false;
|
|
45
|
+
const mark = () => {
|
|
46
|
+
if (gone)
|
|
47
|
+
return;
|
|
48
|
+
gone = true;
|
|
49
|
+
try {
|
|
50
|
+
ctl.abort();
|
|
51
|
+
}
|
|
52
|
+
catch { }
|
|
53
|
+
};
|
|
54
|
+
const onReqAborted = () => mark();
|
|
55
|
+
const onReqClose = () => { if (!req || req.complete === false)
|
|
56
|
+
mark(); };
|
|
57
|
+
const onResClose = () => { if (!res || !(res.writableEnded || res.writableFinished))
|
|
58
|
+
mark(); };
|
|
59
|
+
const on = (target, ev, fn) => {
|
|
60
|
+
// 测试里的假 req/res 未必是 EventEmitter:没有 on 就静默跳过(既有用例都是这种形状)
|
|
61
|
+
if (!target || typeof target.on !== 'function')
|
|
62
|
+
return;
|
|
63
|
+
try {
|
|
64
|
+
target.on(ev, fn);
|
|
65
|
+
}
|
|
66
|
+
catch { }
|
|
67
|
+
};
|
|
68
|
+
const off = (target, ev, fn) => {
|
|
69
|
+
if (!target || typeof target.removeListener !== 'function')
|
|
70
|
+
return;
|
|
71
|
+
try {
|
|
72
|
+
target.removeListener(ev, fn);
|
|
73
|
+
}
|
|
74
|
+
catch { }
|
|
75
|
+
};
|
|
76
|
+
on(req, 'aborted', onReqAborted);
|
|
77
|
+
on(req, 'close', onReqClose);
|
|
78
|
+
on(res, 'close', onResClose);
|
|
79
|
+
return {
|
|
80
|
+
signal: ctl.signal,
|
|
81
|
+
gone: () => gone,
|
|
82
|
+
dispose() {
|
|
83
|
+
off(req, 'aborted', onReqAborted);
|
|
84
|
+
off(req, 'close', onReqClose);
|
|
85
|
+
off(res, 'close', onResClose);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
23
89
|
export function createAsrHandler(opts) {
|
|
24
90
|
return async function handleAsr(req, res) {
|
|
91
|
+
// 先挂上断连观察器:从读 body 到外呼返回的整段都在它的看护下,结束时必须 dispose(finally)
|
|
92
|
+
const client = watchClientGone(req, res);
|
|
25
93
|
try {
|
|
26
94
|
const parsed = await readJsonBody(req, ASR_MAX_BODY);
|
|
27
95
|
if (!parsed.ok) {
|
|
@@ -102,9 +170,18 @@ export function createAsrHandler(opts) {
|
|
|
102
170
|
'content-type': 'multipart/form-data; boundary=' + boundary,
|
|
103
171
|
},
|
|
104
172
|
body: payload,
|
|
105
|
-
}, ASR_TIMEOUT);
|
|
173
|
+
}, ASR_TIMEOUT, client.signal);
|
|
106
174
|
}
|
|
107
175
|
catch (err) {
|
|
176
|
+
// 客户端已经走了(30s 硬超时/切页面/断网重发):这次外呼是**我们自己** abort 的,
|
|
177
|
+
// 不是上游超时、也不是网络故障。处理口径:
|
|
178
|
+
// · 不写缓存(没拿到结果,也避免把半截状态记进缓存);
|
|
179
|
+
// · 不记失败(不计入 ASR-TIMEOUT/ASR-NETWORK 的排障口径),只留专用诊断;
|
|
180
|
+
// · 不回包(连接已经断了,没人收;写了也只是往死 socket 上叠一次写)。
|
|
181
|
+
if (client.gone()) {
|
|
182
|
+
console.error(LOG_CLIENT_ABORT_OUTBOUND);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
108
185
|
// 超时/网络错误:用户看到的仍是 ok:false + 空文本,日志里留标签
|
|
109
186
|
if (isTimeoutError(err))
|
|
110
187
|
console.error('[asr] ASR-TIMEOUT ' + ASR_TIMEOUT + 'ms 无响应,已中止');
|
|
@@ -135,9 +212,19 @@ export function createAsrHandler(opts) {
|
|
|
135
212
|
}
|
|
136
213
|
catch (err) {
|
|
137
214
|
try {
|
|
215
|
+
// 连请求体都没收完客户端就断了(req 'aborted' / ECONNRESET):同上,不算上游失败、不回包
|
|
216
|
+
if (client.gone()) {
|
|
217
|
+
console.error(LOG_CLIENT_ABORT_REQUEST);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
138
220
|
sendError(res, err, { text: '' });
|
|
139
221
|
}
|
|
140
222
|
catch { }
|
|
141
223
|
}
|
|
224
|
+
finally {
|
|
225
|
+
// 结束就摘监听:req/res 是每请求对象,不摘也不会串到下一个请求,但已经结束的 handler
|
|
226
|
+
// 没必要继续响应 'close'/'aborted'(多做一次无意义的 abort),排障时也容易看花眼
|
|
227
|
+
client.dispose();
|
|
228
|
+
}
|
|
142
229
|
};
|
|
143
230
|
}
|
package/dist/host/routes/tts.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* host/routes/tts.ts —— 语音合成路由(可复用模块,带缓存)
|
|
3
|
-
* HTTP
|
|
3
|
+
* HTTP/缓存/参数校验在本文件;「怎么合成」交给**装配层注入**的 TtsEngine(host/tts-engine.ts 的 edge-tts)。
|
|
4
4
|
* 缓存:文本+音色+语速+音调 hash → 复用 mp3(省时间、省调用)
|
|
5
5
|
* 语速由请求体 rate 决定(说书 -15%,普通聊天 +0%),服务端不再写死。
|
|
6
|
+
* 音色:先过 `^[a-zA-Z0-9-]+$` 字符集,再用 shared/voices.ts 的白名单(与客户端菜单同一份);
|
|
7
|
+
* 白名单外一律回落默认音色(不 400,避免破坏旧调用方),响应头 x-tts-voice: fallback 便于排障。
|
|
6
8
|
*/
|
|
7
9
|
import { hashKey } from '../../shared/hash.js';
|
|
8
10
|
import { sendTooLarge } from '../http.js';
|
|
9
11
|
import { readJsonBody } from '../body.js';
|
|
10
12
|
import { sendJson, sendBuffer } from '../respond.js';
|
|
11
13
|
import { normalizeRate, TTS_UNAVAILABLE } from '../../shared/tts.js';
|
|
12
|
-
import {
|
|
14
|
+
import { DEFAULT_VOICE, normalizeVoiceName } from '../../shared/voices.js';
|
|
13
15
|
/** 单次合成的安全上限:客户端已按 80 字分段,这里只防整本大 JSON 被一次送进来 */
|
|
14
16
|
const MAX_TEXT = 6000;
|
|
15
17
|
/** 请求体上限:80 字一段的 JSON 只有几百字节,64KB 足够;超了多半是误发了整本数据 */
|
|
@@ -30,8 +32,14 @@ function oneLine(err, max = 220) {
|
|
|
30
32
|
return String((err && err.message) || err || '').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
31
33
|
}
|
|
32
34
|
export function createTtsHandler(opts) {
|
|
35
|
+
// 引擎必须由装配层构造并注入(src/index.ts),路由内**不再**自建:
|
|
36
|
+
// createEdgeTtsEngine 持有常驻 python 子进程,而路由工厂只交出 handler 函数,
|
|
37
|
+
// 自建的实例句柄谁也拿不到 → 插件卸载/热重载时没有对象可以 dispose()(B92 封装债,B93 去兜底)。
|
|
38
|
+
// 运行时也显式挡一道:JS 调用方漏传时构造即失败,而不是等到合成那一步才 500。
|
|
39
|
+
if (!opts || !opts.engine)
|
|
40
|
+
throw new Error('createTtsHandler: engine 必传(由装配层构造并注入,测试注入桩)');
|
|
33
41
|
const cache = opts.cache;
|
|
34
|
-
const engine = opts.engine
|
|
42
|
+
const engine = opts.engine;
|
|
35
43
|
// 每个 handler 一份闸门状态:测试里多次 createTtsHandler 互不串味
|
|
36
44
|
const failGuard = { streak: 0, until: 0 };
|
|
37
45
|
// 预热常驻 worker(只解析解释器、导入依赖,不合成):首句不必再等 python 启动。
|
|
@@ -63,12 +71,25 @@ export function createTtsHandler(opts) {
|
|
|
63
71
|
return;
|
|
64
72
|
}
|
|
65
73
|
const msg = rawText;
|
|
66
|
-
const voiceIn = typeof body?.voice === 'string' ? body.voice : '';
|
|
74
|
+
const voiceIn = typeof body?.voice === 'string' ? body.voice.trim() : '';
|
|
67
75
|
const isZh = /[\u4e00-\u9fa5]/.test(msg);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
76
|
+
// 默认按文本语言选(中文晓伊 / 英文 Ana):'auto' 与「显式传这两个默认音色」都走这条,保持旧行为
|
|
77
|
+
const langDefault = isZh ? DEFAULT_VOICE : 'en-US-AnaNeural';
|
|
78
|
+
// 第一道:字符集(历史行为,先于白名单)—— 防 CRLF/引号/shell 元字符进日志与参数
|
|
79
|
+
const charsetOk = !!voiceIn && /^[a-zA-Z0-9-]+$/.test(voiceIn);
|
|
80
|
+
// 第二道:白名单,与客户端菜单共用 shared/voices.ts;大小写脏值归一成白名单里的规范写法
|
|
81
|
+
// fallback 传 '' 表示「没命中就交回给我」,便于下面区分「没传音色」与「传了但不在名单」
|
|
82
|
+
const known = charsetOk ? normalizeVoiceName(voiceIn, '') : '';
|
|
83
|
+
// 白名单外(含字符合法但不认识的音色,如 ja-JP-NanamiNeural)→ 回落默认,**不回 400**:
|
|
84
|
+
// 旧调用方传坏值仍要能出声(B92 的「HTTP 直调绕过白名单」在此收口,成本/策略缺口关闭)。
|
|
85
|
+
const voiceFallback = voiceIn !== '' && !known;
|
|
86
|
+
const voice = (!known || known === 'auto' || known === DEFAULT_VOICE || known === 'en-US-AnaNeural')
|
|
87
|
+
? langDefault
|
|
88
|
+
: known;
|
|
89
|
+
// 音频响应头:排障用;只在请求的音色确实被替换时带 x-tts-voice: fallback(正常请求与 auto 不带)
|
|
90
|
+
const audioHeaders = { 'cache-control': 'no-store' };
|
|
91
|
+
if (voiceFallback)
|
|
92
|
+
audioHeaders['x-tts-voice'] = 'fallback';
|
|
72
93
|
// 请求体里带了合法 rate 就用它(说书 -15%),没带默认 +0%
|
|
73
94
|
const rate = normalizeRate(body?.rate, '+0%');
|
|
74
95
|
// 音高:允许请求体指定(带情绪的语气),非法值回落到默认
|
|
@@ -83,7 +104,7 @@ export function createTtsHandler(opts) {
|
|
|
83
104
|
if (cache) {
|
|
84
105
|
const hit = await cache.getJson(ck, 'mp3');
|
|
85
106
|
if (hit) {
|
|
86
|
-
sendBuffer(res, hit, 'audio/mpeg',
|
|
107
|
+
sendBuffer(res, hit, 'audio/mpeg', audioHeaders);
|
|
87
108
|
return;
|
|
88
109
|
}
|
|
89
110
|
}
|
|
@@ -98,6 +119,10 @@ export function createTtsHandler(opts) {
|
|
|
98
119
|
if (cloud && cloud.enabled()) {
|
|
99
120
|
try {
|
|
100
121
|
buf = await cloud.synthesize(msg);
|
|
122
|
+
// 云端不接收 voice(用配置里的 speaker),所以「音色被替换」在云端路径上不成立:
|
|
123
|
+
// 成功出云端时把 fallback 头摘掉,别让排障的人以为客户端音色真的生效了(2026-11-07 B93)。
|
|
124
|
+
if (voiceFallback)
|
|
125
|
+
delete audioHeaders['x-tts-voice'];
|
|
101
126
|
}
|
|
102
127
|
catch (ce) {
|
|
103
128
|
console.warn('[tts] CLOUD-FAIL 回退 edge-tts:' + String(ce?.message || ce).slice(0, 200));
|
|
@@ -116,7 +141,7 @@ export function createTtsHandler(opts) {
|
|
|
116
141
|
}
|
|
117
142
|
catch { }
|
|
118
143
|
}
|
|
119
|
-
sendBuffer(res, buf, 'audio/mpeg',
|
|
144
|
+
sendBuffer(res, buf, 'audio/mpeg', audioHeaders);
|
|
120
145
|
}
|
|
121
146
|
catch (err) {
|
|
122
147
|
try {
|
|
@@ -181,6 +181,12 @@ export function createCloudTts(deps) {
|
|
|
181
181
|
* 重启后才有丢计数的风险(写盘成功时磁盘自然追平,不会重复计数)。
|
|
182
182
|
*/
|
|
183
183
|
let memUsage = null;
|
|
184
|
+
/**
|
|
185
|
+
* 在途预占字数(2026-11-07 B93):判定与记账之间隔着 await fetch,并发 N 个请求
|
|
186
|
+
* 会一起通过判定,最多超额 (N-1)×单次字数。这里在发请求前先「预占」,判定时把预占算进去,
|
|
187
|
+
* 请求结束(成功或失败)在 finally 释放。预占只影响熔断判定,不影响 usage() 报告的已用量。
|
|
188
|
+
*/
|
|
189
|
+
let reservedChars = 0;
|
|
184
190
|
/** 今天已用字数:磁盘与内存取大(磁盘可能是别的实例写的,内存是本进程的最低保障) */
|
|
185
191
|
function usedChars(today) {
|
|
186
192
|
const u = readUsage(deps.runtimeDir);
|
|
@@ -189,7 +195,8 @@ export function createCloudTts(deps) {
|
|
|
189
195
|
return Math.max(fromDisk, fromMem);
|
|
190
196
|
}
|
|
191
197
|
function budgetLeft(cfg, today) {
|
|
192
|
-
|
|
198
|
+
// 已用量 + 在途预占:并发请求不会同时看到「还有额度」
|
|
199
|
+
return cfg.dailyChars - usedChars(today) - reservedChars;
|
|
193
200
|
}
|
|
194
201
|
return {
|
|
195
202
|
enabled() {
|
|
@@ -219,6 +226,8 @@ export function createCloudTts(deps) {
|
|
|
219
226
|
ctl && ctl.abort();
|
|
220
227
|
}
|
|
221
228
|
catch { } }, timeoutMs);
|
|
229
|
+
// 先占额度再发请求:判定与记账之间的 await 期间,别的请求不能再用同一份额度
|
|
230
|
+
reservedChars += body.length;
|
|
222
231
|
try {
|
|
223
232
|
const res = await fetch(CLOUD_TTS_URL, {
|
|
224
233
|
method: 'POST',
|
|
@@ -277,6 +286,8 @@ export function createCloudTts(deps) {
|
|
|
277
286
|
}
|
|
278
287
|
finally {
|
|
279
288
|
clearTimeout(timer);
|
|
289
|
+
// 释放预占:成功时已记进用量(磁盘/内存),失败时这份额度要还回去
|
|
290
|
+
reservedChars = Math.max(0, reservedChars - body.length);
|
|
280
291
|
}
|
|
281
292
|
},
|
|
282
293
|
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shared/voices.ts —— 朗读音色白名单与归一化(宿主 /tts 与浏览器菜单共用同一份)
|
|
3
|
+
*
|
|
4
|
+
* 为什么放在 shared:白名单原先只在 client/voice/voices.ts,宿主 routes/tts.ts 只做
|
|
5
|
+
* `^[a-zA-Z0-9-]+$` 字符集校验,HTTP 直调传 `ja-JP-NanamiNeural` 这类白名单外音色
|
|
6
|
+
* 会被原样接受(B92 取证:策略/成本缺口,不是命令注入)。提到 shared 后两边共用
|
|
7
|
+
* TTS_VOICES,宿主也能按同一份名单回落,不再各自维护一份。
|
|
8
|
+
*
|
|
9
|
+
* 像 'auto' 这样的值不在 edge-tts 的语音表里,但宿主认它:按文本语言自动选晓伊/Ana。
|
|
10
|
+
*
|
|
11
|
+
* 依赖方向:shared 只依赖 shared/immutable.ts,不 import client/host。
|
|
12
|
+
*/
|
|
13
|
+
import { deepFreeze } from './immutable.js';
|
|
14
|
+
/** 可用音色 —— 与菜单「换个声音 ▸」的列表一一对应 */
|
|
15
|
+
export const TTS_VOICES = [
|
|
16
|
+
'zh-CN-XiaoyiNeural',
|
|
17
|
+
'zh-CN-XiaoxiaoNeural',
|
|
18
|
+
'zh-CN-XiaochenNeural',
|
|
19
|
+
'zh-CN-YunxiNeural',
|
|
20
|
+
'zh-CN-YunyangNeural',
|
|
21
|
+
'en-US-AnaNeural',
|
|
22
|
+
'en-US-JennyNeural',
|
|
23
|
+
'en-GB-SoniaNeural',
|
|
24
|
+
'auto',
|
|
25
|
+
];
|
|
26
|
+
deepFreeze(TTS_VOICES);
|
|
27
|
+
/** 默认音色:晓伊(与菜单默认与 C# 版一致) */
|
|
28
|
+
export const DEFAULT_VOICE = 'zh-CN-XiaoyiNeural';
|
|
29
|
+
/**
|
|
30
|
+
* 白名单查表(小写名 → 规范写法)。
|
|
31
|
+
* 大小写不敏感是刻意的(2026-11-07):手改 localStorage / 旧配置里出现
|
|
32
|
+
* "EN-US-ANANEURAL" 这类只差大小写的**合法**音色时,旧实现因为 indexOf 精确匹配
|
|
33
|
+
* 直接判成非法,把用户选的音色静默重置成晓伊。查表后返回的是白名单里的规范写法,
|
|
34
|
+
* 送给 edge-tts 的仍是大小写正确的名字。
|
|
35
|
+
* 这张表只在模块加载时构建一次、不导出,外部改不到。
|
|
36
|
+
*/
|
|
37
|
+
const VOICE_BY_KEY = (() => {
|
|
38
|
+
const m = new Map();
|
|
39
|
+
for (const v of TTS_VOICES)
|
|
40
|
+
m.set(v.toLowerCase(), v);
|
|
41
|
+
return m;
|
|
42
|
+
})();
|
|
43
|
+
/**
|
|
44
|
+
* 音色名规范化:白名单外(含空值/坏值)一律回退。
|
|
45
|
+
* @param name 待规范的音色名(来自 localStorage / 菜单 / HTTP 请求体,可能是任意坏值)
|
|
46
|
+
* @param fallback 未命中时的返回值,默认 DEFAULT_VOICE;宿主可传 '' 表示「没命中」以便自行回落
|
|
47
|
+
*/
|
|
48
|
+
export function normalizeVoiceName(name, fallback = DEFAULT_VOICE) {
|
|
49
|
+
// String(x) 本身会抛(Object.create(null)、自定义 toString 抛错):
|
|
50
|
+
// 音色名从 localStorage / 菜单参数 / 请求体来,坏值只能回退,不能让整条朗读链崩在这里。
|
|
51
|
+
let n = '';
|
|
52
|
+
try {
|
|
53
|
+
n = (name == null ? '' : String(name)).trim();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return fallback;
|
|
57
|
+
}
|
|
58
|
+
if (!n)
|
|
59
|
+
return fallback;
|
|
60
|
+
const hit = VOICE_BY_KEY.get(n.toLowerCase());
|
|
61
|
+
return hit || fallback;
|
|
62
|
+
}
|
|
@@ -12,14 +12,14 @@
|
|
|
12
12
|
|
|
13
13
|
| 项 | 值 |
|
|
14
14
|
| --- | --- |
|
|
15
|
-
| `package.json` version | 1.1.
|
|
16
|
-
| `src/client/version.ts` VERSION | 1.1.
|
|
17
|
-
| 最近 Gitee tag | `v1.1.
|
|
15
|
+
| `package.json` version | 1.1.65 |
|
|
16
|
+
| `src/client/version.ts` VERSION | 1.1.65(由 `scripts/gen-version.mjs` 生成) |
|
|
17
|
+
| 最近 Gitee tag | `v1.1.65`(见 `git tag --list`) |
|
|
18
18
|
| Gitee `main` HEAD | `git log -1` 实读 |
|
|
19
19
|
| GitHub `main` | 发布后 `node tools/gh-verify.mjs v1.1.62` 核对(脚本按 tag 名推期望版本) |
|
|
20
|
-
| npm latest | **1.1.
|
|
20
|
+
| npm latest | **1.1.65**(发布后轮询确认) |
|
|
21
21
|
| README 版本 | 无硬编码版本号 |
|
|
22
|
-
| `npm test` | **
|
|
22
|
+
| `npm test` | **3914 项**(54 个套件,`node tools/test-count.mjs` 核准、失败 0) |
|
|
23
23
|
| `npm pack --dry-run` | **129 个文件 / 17.1 MB**(包);`data/cache`、运行数据、`config.json`、`src/`、`scripts/`、`tools/` 均不进包 |
|
|
24
24
|
## 一、版本号规则
|
|
25
25
|
|
|
@@ -368,3 +368,14 @@ Measure-Command { Invoke-RestMethod -Method Post -Uri http://127.0.0.1:3080/api/
|
|
|
368
368
|
| 6 | 纯朗读后状态回 idle | 没开麦时念完显示「在听」 | 关待命→让它读一段:读完状态应回 idle,不是「在听」 |
|
|
369
369
|
| 7 | 纯朗读实例也有看门狗 | 音频不播不报错时永久 `ttsBusy` → 朗读全哑 | 长时间朗读后仍能继续朗读(不必刷新) |
|
|
370
370
|
| 8 | 连说两遍「停下来吧」也能停 | ≥4 字打断句被 dup 闸门吞 | 说书中连说两次「停下来吧」:第二声也应停 |
|
|
371
|
+
|
|
372
|
+
## 二十一、2026-11-07 凌晨批次(B93:1.1.65)待重启核对
|
|
373
|
+
|
|
374
|
+
| # | 改动 | 为什么 | 怎么验证 |
|
|
375
|
+
|---|------|--------|----------|
|
|
376
|
+
| 1 | `/tts` 校验音色白名单 | 原来 HTTP 直调可绕过客户端白名单 | 直调 `voice=ja-JP-NanamiNeural`:应 200 + 默认音色 + `x-tts-voice: fallback` |
|
|
377
|
+
| 2 | `/tts` engine 必传 | 路由内自建引擎会让卸载时没有句柄 dispose | 反复禁用/启用插件:`tasklist` 不应堆积 python |
|
|
378
|
+
| 3 | ASR 断连即中止 | 客户端 30s 超时重试后宿主仍跑满 60s(白付识别费) | 弱网下打断/关页面:日志应出现 `ASR-CLIENT-ABORT`,不再有 60s 的 `ASR-TIMEOUT` |
|
|
379
|
+
| 4 | 云端预算并发不超额 | 并发请求会同时通过日预算判定 | 并发触发多段云端合成:日用量不应超过 `dailyChars` |
|
|
380
|
+
| 5 | 朗读队列丢段补 onDone | >200 段时被丢的段没人结账,叙述锁只能等自愈 | 极端长文本后仍能继续朗读(不必刷新) |
|
|
381
|
+
| 6 | 云端成功不再报 fallback | 云端用配置说话人,头会误导排障 | 开云端后请求:响应头不应出现 `x-tts-voice` |
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
> 架构宪章:**模块只对外暴露这些导出,外部不得读取模块内部实现**。
|
|
6
6
|
> 新增/修改导出 = 接口变更,必须同步调用点并在提交信息里写明。
|
|
7
7
|
|
|
8
|
-
> 统计:
|
|
8
|
+
> 统计:120 个模块文件,722 个导出项。
|
|
9
9
|
|
|
10
10
|
## 宿主(Node)(`src/host/`)
|
|
11
11
|
|
|
@@ -124,7 +124,7 @@
|
|
|
124
124
|
| `voice/fetch.ts` | `fetchWithTimeout` |
|
|
125
125
|
| `voice/recorder.ts` | `APE_MIN_SPEECH_MS`、`CLASSIC_SPEECH_DB`、`EARLY_STOP_QUIET`、`LEVEL_PROBE_MS`、`LevelBox`、`LevelRead`、`MIN_SEGMENT_BYTES`、`MIN_SPEECH_MS`、`MUTED_SPEECH_DB`、`SILENT_PEAK_DB`、`SegmentState`、`TICK_ERROR_MS`、`TICK_MS`、`WINDOW_MS`、`accrueClassic`、`addChunk`、`clearChunks`、`createSegmentState`、`hadApeSpeech`、`isSilentPeak`、`isTooSmallSegment`、`notePeak`、`readLevel`、`resetSegmentState`、`shouldStopApeNoLevel`、`shouldStopClassic`、`takeBlob` |
|
|
126
126
|
| `voice/status.ts` | `DiagSnapshots`、`StatusHolder`、`VoiceStats`、`VoiceStatus`、`createDiagSnapshots`、`createStatusHolder`、`createVoiceStats` |
|
|
127
|
-
| `voice/tts-queue.ts` | `ECHO_MUTE_MS`、`TTS_QUEUE_MAX`、`TTS_RETRY_BASE_MS`、`TTS_RETRY_MAX`、`TTS_TIMEOUT_MS`、`TTS_WATCH_MS`、`TTS_ZOMBIE_MS`、`TtsJob`、`buildTtsJobs`、`capQueue`、`isStaleToken`、`narrationPauseFor`、`prefetchTargets`、`ttsRetryDelayMs` |
|
|
127
|
+
| `voice/tts-queue.ts` | `ECHO_MUTE_MS`、`TTS_QUEUE_MAX`、`TTS_RETRY_BASE_MS`、`TTS_RETRY_MAX`、`TTS_TIMEOUT_MS`、`TTS_WATCH_MS`、`TTS_ZOMBIE_MS`、`TtsJob`、`buildTtsJobs`、`capQueue`、`capQueueDetailed`、`isStaleToken`、`narrationPauseFor`、`prefetchTargets`、`ttsRetryDelayMs` |
|
|
128
128
|
| `voice/voices.ts` | `DEFAULT_VOICE`、`TTS_VOICES`、`normalizeVoiceName`、`readStoredVoiceName` |
|
|
129
129
|
| `wake.ts` | `WAKE_WINDOW_MS`、`WAKE_WORDS`、`WAKE_WORD_KEY`、`WakeCheck`、`WakeGate`、`createWakeGate`、`loadCustomWakeWords`、`normalizeWakeText`、`setCustomWakeWord`、`stripWakeWords` |
|
|
130
130
|
|
|
@@ -140,3 +140,4 @@
|
|
|
140
140
|
| `sse.ts` | `SseParseResult`、`parseSseBuffer` |
|
|
141
141
|
| `time.ts` | `ParsedDue`、`cleanReminderText`、`formatDue`、`parseDue` |
|
|
142
142
|
| `tts.ts` | `EdgeTtsOptions`、`TTS_PROBE_CODE`、`TTS_UNAVAILABLE`、`buildEdgeTtsArgs`、`cleanTtsText`、`isValidPitch`、`isValidRate`、`normalizeRate`、`splitTtsSegments` |
|
|
143
|
+
| `voices.ts` | `DEFAULT_VOICE`、`TTS_VOICES`、`normalizeVoiceName` |
|
|
@@ -145,3 +145,6 @@
|
|
|
145
145
|
| B92a | 2026-11-07 | 主代理:`capSampleRate` 修「192kHz 设备半速音频」(头与数据同采样率)/ `/tts` 超长改 413(不再静默截断);`test-audio-sim` +6、`test-voice-routes` +2 | 双 tsc 0 / 3799 项 0 失败 | 见 git |
|
|
146
146
|
| B92b | 2026-11-07 | 宿主语音路由分身:`/asr` 解码后超限 200→413 / 缓存键补 `format`(v1→v2);新增 `test-voice-routes.mjs`(23) | 双 tsc 0 / 3799 项 0 失败 | 见 git |
|
|
147
147
|
| B92c | 2026-11-07 | 朗读播放路径分身:`pitch` 丢失 / 没开麦报「在听」/ 纯朗读无看门狗 / `ttsRequests` 虚高 / 打断整句被 dup 吞;新增 `test-voice-tts.mjs`(99) | 双 tsc 0 / 3799 项 0 失败 | 见 git |
|
|
148
|
+
| B93a | 2026-11-07 | 主代理:云端日预算**预占**(并发不再超额)/ `capQueueDetailed` 丢段结账 `onDone` 并清预取 / 云端成功不再谎报 `x-tts-voice: fallback`;`test-persona-cloud` +3、`test-voice-queue` +4、`test-voice-routes` +2 | 双 tsc 0 / 3914 项 0 失败 | 见 git |
|
|
149
|
+
| B93b | 2026-11-07 | 音色白名单分身:新建 `shared/voices.ts`(client/host 同一份)/ `/tts` 名单外回落默认 + `x-tts-voice: fallback` / engine 必传去兜底 / 删 `ports.voice` 死字段;新增 `test-voices-shared.mjs`(60) | 双 tsc 0 / 3914 项 0 失败 | 见 git |
|
|
150
|
+
| B93c | 2026-11-07 | ASR 断连中止分身:`watchClientGone` 三判定(不能只看 close)/ 断开 abort 上游、`AbortError` 不写缓存不计失败 / `fetchJson` 支持外部 signal(原被超时覆盖);新增 `test-asr-abort.mjs`(46) | 双 tsc 0 / 3914 项 0 失败 | 见 git |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-chubby-whale",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.65",
|
|
4
4
|
"description": "DSH 网页版桌面宠物「肥鲸」——蓝发鲸鱼娘,固定在 harness 页面右下角:会眨眼、能拖拽、点击可聊天,支持语音对话、念诗说书、饭点提醒。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"scripts": {
|
|
75
75
|
"build": "node scripts/gen-version.mjs && node scripts/check-bom.mjs && tsc -p tsconfig.json && tsc -p tsconfig.client.json && esbuild src/client.ts --bundle --format=iife --external:react --charset=utf8 --outfile=dist/client.js",
|
|
76
76
|
"prepublishOnly": "npm run build",
|
|
77
|
-
"test": "tsc -p tsconfig.json && tsc -p tsconfig.client.json && node scripts/check-imports.mjs && node scripts/gen-module-index.mjs --check && node scripts/check-boundaries.mjs && node scripts/check-diag-keys.mjs && node scripts/check-text.mjs && node scripts/test.mjs && node scripts/test-chat-limits.mjs && node scripts/test-stream-ledger.mjs && node scripts/test-story-security.mjs && node scripts/test-behavior-regressions.mjs && node scripts/verify-voice.mjs && node scripts/smoke-client.mjs && node scripts/ape-bench.mjs && node scripts/smoke-host.mjs && node scripts/test-voice-queue.mjs && node scripts/test-tts-engine.mjs && node scripts/test-data-layer.mjs && node scripts/test-repos.mjs && node scripts/test-personality.mjs && node scripts/test-story-player.mjs && node scripts/test-chat-routes.mjs && node scripts/test-menu-actions.mjs && node scripts/test-story-client.mjs && node scripts/test-story-auto.mjs && node scripts/test-content-routes.mjs && node scripts/test-dataport-poem.mjs && node scripts/test-render-layer.mjs && node scripts/test-store-cache.mjs && node scripts/test-actions-rest.mjs && node scripts/test-misc-routes.mjs && node scripts/test-chat-client.mjs && node scripts/test-prefs-config.mjs && node scripts/test-media-reminders.mjs && node scripts/test-image-reader.mjs && node scripts/test-boundaries-guard.mjs && node scripts/test-assembly.mjs && node scripts/test-asr-concurrency.mjs && node scripts/test-host-http.mjs && node scripts/test-entry-narration.mjs && node scripts/test-poem-narration.mjs && node scripts/test-multidrop.mjs && node scripts/test-utils-pure.mjs && node scripts/test-net-client.mjs && node scripts/test-persona-cloud.mjs && node scripts/test-client-glue.mjs && node scripts/test-poem-titles-retry.mjs && node scripts/test-shared-voice.mjs && node scripts/test-audio-sim.mjs && node scripts/test-voice-routes.mjs && node scripts/test-voice-tts.mjs && node scripts/test-voice-bridge.mjs && node scripts/platform-check.mjs",
|
|
77
|
+
"test": "tsc -p tsconfig.json && tsc -p tsconfig.client.json && node scripts/check-imports.mjs && node scripts/gen-module-index.mjs --check && node scripts/check-boundaries.mjs && node scripts/check-diag-keys.mjs && node scripts/check-text.mjs && node scripts/test.mjs && node scripts/test-chat-limits.mjs && node scripts/test-stream-ledger.mjs && node scripts/test-story-security.mjs && node scripts/test-behavior-regressions.mjs && node scripts/verify-voice.mjs && node scripts/smoke-client.mjs && node scripts/ape-bench.mjs && node scripts/smoke-host.mjs && node scripts/test-voice-queue.mjs && node scripts/test-tts-engine.mjs && node scripts/test-data-layer.mjs && node scripts/test-repos.mjs && node scripts/test-personality.mjs && node scripts/test-story-player.mjs && node scripts/test-chat-routes.mjs && node scripts/test-menu-actions.mjs && node scripts/test-story-client.mjs && node scripts/test-story-auto.mjs && node scripts/test-content-routes.mjs && node scripts/test-dataport-poem.mjs && node scripts/test-render-layer.mjs && node scripts/test-store-cache.mjs && node scripts/test-actions-rest.mjs && node scripts/test-misc-routes.mjs && node scripts/test-chat-client.mjs && node scripts/test-prefs-config.mjs && node scripts/test-media-reminders.mjs && node scripts/test-image-reader.mjs && node scripts/test-boundaries-guard.mjs && node scripts/test-assembly.mjs && node scripts/test-asr-concurrency.mjs && node scripts/test-host-http.mjs && node scripts/test-entry-narration.mjs && node scripts/test-poem-narration.mjs && node scripts/test-multidrop.mjs && node scripts/test-utils-pure.mjs && node scripts/test-net-client.mjs && node scripts/test-persona-cloud.mjs && node scripts/test-client-glue.mjs && node scripts/test-poem-titles-retry.mjs && node scripts/test-shared-voice.mjs && node scripts/test-audio-sim.mjs && node scripts/test-voice-routes.mjs && node scripts/test-voices-shared.mjs && node scripts/test-voice-tts.mjs && node scripts/test-asr-abort.mjs && node scripts/test-voice-bridge.mjs && node scripts/platform-check.mjs",
|
|
78
78
|
"version:check": "node tools/version-check.mjs",
|
|
79
79
|
"typecheck": "node scripts/gen-version.mjs && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
|
|
80
80
|
"smoke:client": "node scripts/smoke-client.mjs",
|