dsh-chubby-whale 1.1.63 → 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 +47 -0
- package/dist/client.js +91 -17
- package/dist/host/http.js +95 -5
- package/dist/host/routes/asr.js +102 -5
- package/dist/host/routes/tts.js +44 -11
- 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 +24 -0
- package/docs//346/250/241/345/235/227/346/216/245/345/217/243/346/270/205/345/215/225.md +4 -3
- package/docs//350/267/250/345/271/263/345/217/260/344/270/216/345/210/206/345/267/245.md +6 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,53 @@
|
|
|
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
|
+
|
|
33
|
+
## [1.1.64] - 2026-11-07
|
|
34
|
+
|
|
35
|
+
### 修复(B92 客户端朗读播放路径分身)
|
|
36
|
+
- **`pitch` 选项完全失效**:`buildTtsJobs` 既不收也不复制 `pitch`,而 `voice.ts` 拼 `/tts` 请求体时读 `job.pitch` → 永远是空串;念诗明明传了 `+15Hz`(音高从未生效)
|
|
37
|
+
- **没开麦克风却显示「在听」**:`pumpTts` 队列空时无条件 `setStatus('listening')`,而 `stopSpeaking` 是 `running ? listening : idle`;试听/说书走 `ensure()` 不开麦,念完状态就变成「在听」
|
|
38
|
+
- **纯朗读实例没有看门狗**:旧实现只在 `start()` 里装 `setInterval`,而 `ensure()` 建的实例从不 `start()` → 音频元素不播也不报错时 `ttsBusy` 永久为真,之后每句只排队(表现「朗读全哑、只能刷新页面」)
|
|
39
|
+
- **诊断计数虚高**:`ttsRequests++` 在冷却分支之前,走系统语音的段也被算成一次 `/tts` 请求
|
|
40
|
+
- **打断整句被 dup 闸门吞**:`isRepeat(minLen:4)` 对 ≥4 字整句生效 —— 说书时连说两遍「停下来吧」「不要说了」,第二声被判重复丢弃;而 `voice-bridge` 的停止正则本应能停。现在打断整句跳过 short/dup 闸门(不去放宽 `sim.ts`,避免削弱短句去重)
|
|
41
|
+
|
|
42
|
+
### 修复(B92 宿主语音路由分身)
|
|
43
|
+
- **`/asr` 解码后超限回 200**:同一路由「请求体超限」走 413、「解码后超限」却回 200,代理/日志会把超限记成识别成功;统一 413
|
|
44
|
+
- **`/asr` 缓存键缺 `format`**:同一份字节先以 `wav` 识别、再以 `webm` 上传会直接命中旧文本(实测 `fetch=1`、`cached:true`);键升 v2 并加入 format,旧条目自然失效
|
|
45
|
+
|
|
46
|
+
### 修复(主代理)
|
|
47
|
+
- **设备 192kHz 导致半速音频**:`AudioContext.sampleRate` 跟随输出设备,192k 设备上数据是 192k 而 WAV 头被夹到 96k → ASR 整段识别必错。新增 `capSampleRate`(整数倍块平均)先降采样,**头与数据同采样率**
|
|
48
|
+
- **`/tts` 静默截断**:6001 字回 HTTP 200 但引擎只收到 6000 字(听到半句、无任何信号);改为 413 `{error:'text too large', limit:6000}`,正好 6000 仍正常
|
|
49
|
+
|
|
50
|
+
### 测试
|
|
51
|
+
- 新增 `test-voice-routes.mjs`(23)、`test-voice-tts.mjs`(99),接入 `npm test`;`test-audio-sim` 补 6 条采样率断言
|
|
52
|
+
- 全链 **3799 项 0 失败**(`node tools/test-count.mjs`:52 个套件,失败 0,未识别 0)
|
|
53
|
+
|
|
7
54
|
## [1.1.63] - 2026-11-07
|
|
8
55
|
|
|
9
56
|
### 修复(B91 共享层与语音子模块分身)
|
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;
|
|
@@ -799,6 +799,29 @@
|
|
|
799
799
|
}
|
|
800
800
|
return buf;
|
|
801
801
|
}
|
|
802
|
+
function capSampleRate(samples, sampleRate, maxRate = 96e3) {
|
|
803
|
+
const src = samples || [];
|
|
804
|
+
const n = Math.max(0, src.length >>> 0);
|
|
805
|
+
const rate = Math.round(Number(sampleRate) || 48e3);
|
|
806
|
+
const cap = Math.max(8e3, Math.round(Number(maxRate) || 96e3));
|
|
807
|
+
if (rate <= cap || n === 0) {
|
|
808
|
+
const same = src instanceof Float32Array ? src : Float32Array.from(src);
|
|
809
|
+
return { samples: same, sampleRate: rate };
|
|
810
|
+
}
|
|
811
|
+
const factor = Math.max(2, Math.ceil(rate / cap));
|
|
812
|
+
const outLen = Math.floor(n / factor);
|
|
813
|
+
const out = new Float32Array(outLen);
|
|
814
|
+
for (let i = 0; i < outLen; i++) {
|
|
815
|
+
let sum = 0;
|
|
816
|
+
const base = i * factor;
|
|
817
|
+
for (let k = 0; k < factor; k++) {
|
|
818
|
+
const v = Number(src[base + k]);
|
|
819
|
+
sum += isFinite(v) ? v : 0;
|
|
820
|
+
}
|
|
821
|
+
out[i] = sum / factor;
|
|
822
|
+
}
|
|
823
|
+
return { samples: out, sampleRate: Math.round(rate / factor) };
|
|
824
|
+
}
|
|
802
825
|
function wavBytes(seconds, sampleRate) {
|
|
803
826
|
const sec = Math.max(0, Number(seconds) || 0);
|
|
804
827
|
const sr = Math.max(8e3, Math.min(96e3, Math.round(Number(sampleRate) || 48e3)));
|
|
@@ -1007,11 +1030,14 @@
|
|
|
1007
1030
|
const n = Math.min(2, Number(buf.numberOfChannels) || 1);
|
|
1008
1031
|
for (let i = 0; i < n; i++) chans.push(buf.getChannelData(i));
|
|
1009
1032
|
const mono = downmixToMono(chans);
|
|
1010
|
-
|
|
1033
|
+
const capped = capSampleRate(mono, buf.sampleRate);
|
|
1034
|
+
const samples = capped.samples;
|
|
1035
|
+
const rate = capped.sampleRate;
|
|
1036
|
+
if (wavBytes(samples.length / rate, rate) > maxWavBytes) {
|
|
1011
1037
|
done(null);
|
|
1012
1038
|
return;
|
|
1013
1039
|
}
|
|
1014
|
-
done(new Blob([encodeWavPcm16(
|
|
1040
|
+
done(new Blob([encodeWavPcm16(samples, rate)], { type: "audio/wav" }));
|
|
1015
1041
|
} catch (e) {
|
|
1016
1042
|
done(null);
|
|
1017
1043
|
}
|
|
@@ -1157,6 +1183,31 @@
|
|
|
1157
1183
|
}
|
|
1158
1184
|
|
|
1159
1185
|
// src/client/voice/echo-filter.ts
|
|
1186
|
+
var SINGLE_INTERRUPT = /^[停别不]$/;
|
|
1187
|
+
var INTERRUPT_PHRASES = [
|
|
1188
|
+
"停下",
|
|
1189
|
+
"停下来",
|
|
1190
|
+
"停下来吧",
|
|
1191
|
+
"停一下",
|
|
1192
|
+
"停一停",
|
|
1193
|
+
"停吧",
|
|
1194
|
+
"别说了",
|
|
1195
|
+
"别讲了",
|
|
1196
|
+
"别念了",
|
|
1197
|
+
"别吵",
|
|
1198
|
+
"别说下去了",
|
|
1199
|
+
"不要说了",
|
|
1200
|
+
"不用说了",
|
|
1201
|
+
"闭嘴",
|
|
1202
|
+
"安静",
|
|
1203
|
+
"打住",
|
|
1204
|
+
"够了",
|
|
1205
|
+
"暂停",
|
|
1206
|
+
"算了"
|
|
1207
|
+
];
|
|
1208
|
+
function isInterruptCommand(plain) {
|
|
1209
|
+
return SINGLE_INTERRUPT.test(plain) || INTERRUPT_PHRASES.indexOf(plain) >= 0;
|
|
1210
|
+
}
|
|
1160
1211
|
function createEchoFilter() {
|
|
1161
1212
|
const spokenRecent = [];
|
|
1162
1213
|
const recentHeard = [];
|
|
@@ -1178,7 +1229,8 @@
|
|
|
1178
1229
|
}
|
|
1179
1230
|
const plain = normalizePlain(text).toLowerCase();
|
|
1180
1231
|
const plainKey = echoKey(text).toLowerCase();
|
|
1181
|
-
const interrupt =
|
|
1232
|
+
const interrupt = SINGLE_INTERRUPT.test(plain);
|
|
1233
|
+
const stopCmd = isInterruptCommand(plain);
|
|
1182
1234
|
if (plain.length < 2 && !interrupt) return { ok: false, reason: "trivial" };
|
|
1183
1235
|
if (!interrupt && isNoiseText(plain)) return { ok: false, reason: "noise" };
|
|
1184
1236
|
if (/^本鱼在听/.test(plain)) return { ok: false, reason: "self" };
|
|
@@ -1198,8 +1250,8 @@
|
|
|
1198
1250
|
if (plain.length >= 6 && target.indexOf(plain.slice(0, 6)) >= 0) return { ok: false, reason: "echo" };
|
|
1199
1251
|
if (plain.length >= 3 && now - s.at < 6e3 && target.indexOf(plain) >= 0) return { ok: false, reason: "echo" };
|
|
1200
1252
|
}
|
|
1201
|
-
if (isShortEchoFragment(plain, hadRecentSpeech)) return { ok: false, reason: "short" };
|
|
1202
|
-
if (isRepeat(plain, { recent: recentHeard, windowMs: 8e3, threshold: 0.9, minLen: 4, now })) {
|
|
1253
|
+
if (!stopCmd && isShortEchoFragment(plain, hadRecentSpeech)) return { ok: false, reason: "short" };
|
|
1254
|
+
if (!stopCmd && isRepeat(plain, { recent: recentHeard, windowMs: 8e3, threshold: 0.9, minLen: 4, now })) {
|
|
1203
1255
|
return { ok: false, reason: "dup" };
|
|
1204
1256
|
}
|
|
1205
1257
|
recentHeard.push({ text, at: now });
|
|
@@ -1285,7 +1337,7 @@
|
|
|
1285
1337
|
}
|
|
1286
1338
|
}
|
|
1287
1339
|
|
|
1288
|
-
// src/
|
|
1340
|
+
// src/shared/voices.ts
|
|
1289
1341
|
var TTS_VOICES = [
|
|
1290
1342
|
"zh-CN-XiaoyiNeural",
|
|
1291
1343
|
"zh-CN-XiaoxiaoNeural",
|
|
@@ -1307,14 +1359,16 @@
|
|
|
1307
1359
|
function normalizeVoiceName(name, fallback = DEFAULT_VOICE) {
|
|
1308
1360
|
let n = "";
|
|
1309
1361
|
try {
|
|
1310
|
-
n =
|
|
1311
|
-
} catch
|
|
1362
|
+
n = (name == null ? "" : String(name)).trim();
|
|
1363
|
+
} catch {
|
|
1312
1364
|
return fallback;
|
|
1313
1365
|
}
|
|
1314
1366
|
if (!n) return fallback;
|
|
1315
1367
|
const hit = VOICE_BY_KEY.get(n.toLowerCase());
|
|
1316
1368
|
return hit || fallback;
|
|
1317
1369
|
}
|
|
1370
|
+
|
|
1371
|
+
// src/client/voice/voices.ts
|
|
1318
1372
|
function readStoredVoiceName() {
|
|
1319
1373
|
try {
|
|
1320
1374
|
return normalizeVoiceName(localStorage.getItem("chubby-tts-voice"));
|
|
@@ -1414,11 +1468,13 @@
|
|
|
1414
1468
|
}
|
|
1415
1469
|
function buildTtsJobs(segs, opts) {
|
|
1416
1470
|
const out = [];
|
|
1471
|
+
const pitch = opts.pitch ? String(opts.pitch) : void 0;
|
|
1417
1472
|
for (let i = 0; i < segs.length; i++) {
|
|
1418
1473
|
out.push({
|
|
1419
1474
|
seq: opts.nextSeq + i,
|
|
1420
1475
|
text: segs[i],
|
|
1421
1476
|
rate: opts.rate,
|
|
1477
|
+
pitch,
|
|
1422
1478
|
narration: opts.narration,
|
|
1423
1479
|
attempts: 0,
|
|
1424
1480
|
strict: opts.strict,
|
|
@@ -1431,12 +1487,11 @@
|
|
|
1431
1487
|
return typeof token === "number" && token !== current;
|
|
1432
1488
|
}
|
|
1433
1489
|
var TTS_QUEUE_MAX = 200;
|
|
1434
|
-
function
|
|
1490
|
+
function capQueueDetailed(q, max = TTS_QUEUE_MAX) {
|
|
1435
1491
|
const limit = Math.max(0, Math.floor(Number(max) || 0));
|
|
1436
|
-
if (!Array.isArray(q) || q.length <= limit) return
|
|
1492
|
+
if (!Array.isArray(q) || q.length <= limit) return [];
|
|
1437
1493
|
const drop = q.length - limit;
|
|
1438
|
-
q.splice(0, drop);
|
|
1439
|
-
return drop;
|
|
1494
|
+
return q.splice(0, drop);
|
|
1440
1495
|
}
|
|
1441
1496
|
|
|
1442
1497
|
// src/client/voice/recorder.ts
|
|
@@ -1992,6 +2047,10 @@
|
|
|
1992
2047
|
} catch (e) {
|
|
1993
2048
|
}
|
|
1994
2049
|
}
|
|
2050
|
+
function ensureTtsWatch() {
|
|
2051
|
+
if (ttsWatch) return;
|
|
2052
|
+
ttsWatch = setInterval(ttsWatchdog, TTS_WATCH_MS);
|
|
2053
|
+
}
|
|
1995
2054
|
function ttsWatchdog() {
|
|
1996
2055
|
try {
|
|
1997
2056
|
const audioPlaying = !!(ttsAudio && !ttsAudio.paused && !ttsAudio.ended && ttsAudio.readyState > 0);
|
|
@@ -2147,6 +2206,7 @@
|
|
|
2147
2206
|
}
|
|
2148
2207
|
}
|
|
2149
2208
|
function pumpTts(force) {
|
|
2209
|
+
ensureTtsWatch();
|
|
2150
2210
|
if (ttsBusy) return;
|
|
2151
2211
|
if (ttsNextRetryAt > 0) {
|
|
2152
2212
|
if (!force && Date.now() < ttsNextRetryAt) return;
|
|
@@ -2154,7 +2214,7 @@
|
|
|
2154
2214
|
}
|
|
2155
2215
|
const job = ttsQ.shift();
|
|
2156
2216
|
if (!job) {
|
|
2157
|
-
setStatus("listening");
|
|
2217
|
+
setStatus(running ? "listening" : "idle");
|
|
2158
2218
|
return;
|
|
2159
2219
|
}
|
|
2160
2220
|
ttsBusy = true;
|
|
@@ -2164,7 +2224,6 @@
|
|
|
2164
2224
|
const myToken = ++speakToken;
|
|
2165
2225
|
job.token = myToken;
|
|
2166
2226
|
setStatus("speaking");
|
|
2167
|
-
stats.ttsRequests++;
|
|
2168
2227
|
if (ttsInCooldown() && job.attempts <= 0) {
|
|
2169
2228
|
ttsSynthAt = 0;
|
|
2170
2229
|
ttsDiag(job, true, "cooldown");
|
|
@@ -2175,6 +2234,7 @@
|
|
|
2175
2234
|
ttsBrowserFallback(job, myToken);
|
|
2176
2235
|
return;
|
|
2177
2236
|
}
|
|
2237
|
+
stats.ttsRequests++;
|
|
2178
2238
|
const cached = ttsPrefetch.get(job.seq);
|
|
2179
2239
|
const cur = cached || synth(job);
|
|
2180
2240
|
if (cached) {
|
|
@@ -2490,6 +2550,8 @@
|
|
|
2490
2550
|
echoFilter.noteSpoken(raw);
|
|
2491
2551
|
const jobs = buildTtsJobs(segs, {
|
|
2492
2552
|
rate,
|
|
2553
|
+
// 音高一路传到 /tts 的请求体:不在这里交给队列任务,宿主永远只收到空串(B92 修)
|
|
2554
|
+
pitch: o && o.pitch,
|
|
2493
2555
|
narration,
|
|
2494
2556
|
strict,
|
|
2495
2557
|
nextSeq: ttsSeq + 1,
|
|
@@ -2497,8 +2559,20 @@
|
|
|
2497
2559
|
});
|
|
2498
2560
|
ttsSeq += segs.length;
|
|
2499
2561
|
for (let i = 0; i < jobs.length; i++) ttsQ.push(jobs[i]);
|
|
2500
|
-
const
|
|
2501
|
-
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
|
+
}
|
|
2502
2576
|
prefetchUpcoming(!!(ttsQ[0] && ttsQ[0].narration));
|
|
2503
2577
|
pumpTts();
|
|
2504
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
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* host/routes/asr.ts —— 语音识别路由(硅基流动 Qwen3-ASR)
|
|
3
3
|
* POST JSON { audio: base64, format: 'webm'|'wav'|'mp3' } → { text }
|
|
4
4
|
* Key 由入口注入(getKey),路由不再自己读 config.json / 环境变量。
|
|
5
|
-
* 体积:请求体(base64)上限 12MB → 413;解码后音频上限 8MB
|
|
5
|
+
* 体积:请求体(base64)上限 12MB → 413;解码后音频上限 8MB → 同样 413(口径统一,2026-11-07)
|
|
6
|
+
* 外呼超时 60 秒;**客户端断开(30s 硬超时/切页/断网)立刻中止外呼**,不再把付费识别跑满 60s(B93)
|
|
6
7
|
*/
|
|
7
8
|
import { hashBytes } from '../../shared/hash.js';
|
|
8
9
|
import { isInterruptWord, isNoiseText } from '../../shared/noise.js';
|
|
@@ -19,8 +20,76 @@ const ASR_MAX_BODY = 12 * 1024 * 1024;
|
|
|
19
20
|
const ASR_MAX_AUDIO = 8 * 1024 * 1024;
|
|
20
21
|
/** 外呼超时:60 秒 */
|
|
21
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
|
+
}
|
|
22
89
|
export function createAsrHandler(opts) {
|
|
23
90
|
return async function handleAsr(req, res) {
|
|
91
|
+
// 先挂上断连观察器:从读 body 到外呼返回的整段都在它的看护下,结束时必须 dispose(finally)
|
|
92
|
+
const client = watchClientGone(req, res);
|
|
24
93
|
try {
|
|
25
94
|
const parsed = await readJsonBody(req, ASR_MAX_BODY);
|
|
26
95
|
if (!parsed.ok) {
|
|
@@ -49,9 +118,13 @@ export function createAsrHandler(opts) {
|
|
|
49
118
|
return;
|
|
50
119
|
}
|
|
51
120
|
const buf = Buffer.from(audioB64, 'base64');
|
|
52
|
-
// 解码后再判音频上限(8MB
|
|
121
|
+
// 解码后再判音频上限(8MB)。
|
|
122
|
+
// 必须与上面「请求体超限」同一口径回 413:同一个路由对「太大」给出 200/413 两种状态码,
|
|
123
|
+
// 客户端虽只看 ok 字段、表现不变,但代理、网关、访问日志只认状态码——200 会被记成
|
|
124
|
+
// 「识别成功」,排障时查不出来(2026-11-07 B92 实测:8MB+1 字节走的正是这条分支)。
|
|
53
125
|
if (buf.length > ASR_MAX_AUDIO) {
|
|
54
|
-
|
|
126
|
+
console.error('[asr] AUDIO-TOO-LARGE ' + buf.length + ' bytes > ' + ASR_MAX_AUDIO);
|
|
127
|
+
sendJson(res, { ok: false, error: 'audio too large', text: '' }, 413);
|
|
55
128
|
return;
|
|
56
129
|
}
|
|
57
130
|
const key = opts.getKey();
|
|
@@ -61,7 +134,12 @@ export function createAsrHandler(opts) {
|
|
|
61
134
|
}
|
|
62
135
|
// 用完整音频字节做 key(只取前 4000 字符容易撞 key)
|
|
63
136
|
// key 里带模型名:换 ASR 模型后不会命中旧文本
|
|
64
|
-
|
|
137
|
+
// key 里还必须带 format:同一份字节在 multipart 里声明的 Content-Type / filename 不同,
|
|
138
|
+
// 上游的解码方式就不同。原来只按「模型 + 字节」缓存,实测同一份字节先用 format=wav
|
|
139
|
+
// 识别成功,再以 format=webm 上传会直接命中上一次的文本(cached:true)——A 容器的结果
|
|
140
|
+
// 顶了 B 容器的请求。v1 前缀的旧条目没有 format,刻意升到 v2 让它们自然失效(7 天 TTL 内
|
|
141
|
+
// 最多少几次命中,不会读出别人的文本)。
|
|
142
|
+
const ck = hashBytes(Buffer.concat([Buffer.from('v2|' + ASR_MODEL + '|' + format + '|'), buf]));
|
|
65
143
|
if (opts.cache) {
|
|
66
144
|
const hit = await opts.cache.getJson(ck, 'json', ASR_CACHE_MAX_AGE);
|
|
67
145
|
if (hit && typeof hit.text === 'string') {
|
|
@@ -92,9 +170,18 @@ export function createAsrHandler(opts) {
|
|
|
92
170
|
'content-type': 'multipart/form-data; boundary=' + boundary,
|
|
93
171
|
},
|
|
94
172
|
body: payload,
|
|
95
|
-
}, ASR_TIMEOUT);
|
|
173
|
+
}, ASR_TIMEOUT, client.signal);
|
|
96
174
|
}
|
|
97
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
|
+
}
|
|
98
185
|
// 超时/网络错误:用户看到的仍是 ok:false + 空文本,日志里留标签
|
|
99
186
|
if (isTimeoutError(err))
|
|
100
187
|
console.error('[asr] ASR-TIMEOUT ' + ASR_TIMEOUT + 'ms 无响应,已中止');
|
|
@@ -125,9 +212,19 @@ export function createAsrHandler(opts) {
|
|
|
125
212
|
}
|
|
126
213
|
catch (err) {
|
|
127
214
|
try {
|
|
215
|
+
// 连请求体都没收完客户端就断了(req 'aborted' / ECONNRESET):同上,不算上游失败、不回包
|
|
216
|
+
if (client.gone()) {
|
|
217
|
+
console.error(LOG_CLIENT_ABORT_REQUEST);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
128
220
|
sendError(res, err, { text: '' });
|
|
129
221
|
}
|
|
130
222
|
catch { }
|
|
131
223
|
}
|
|
224
|
+
finally {
|
|
225
|
+
// 结束就摘监听:req/res 是每请求对象,不摘也不会串到下一个请求,但已经结束的 handler
|
|
226
|
+
// 没必要继续响应 'close'/'aborted'(多做一次无意义的 abort),排障时也容易看花眼
|
|
227
|
+
client.dispose();
|
|
228
|
+
}
|
|
132
229
|
};
|
|
133
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 启动。
|
|
@@ -54,13 +62,34 @@ export function createTtsHandler(opts) {
|
|
|
54
62
|
return;
|
|
55
63
|
}
|
|
56
64
|
const body = parsed.body;
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
|
|
65
|
+
const rawText = typeof body?.text === 'string' ? body.text.trim() : '';
|
|
66
|
+
// 超长不再静默截断(2026-11-07 B92 审计):原来 slice(0, MAX_TEXT) 会回 200,
|
|
67
|
+
// 但引擎只收到前 6000 字 —— 调用方听到的是半句话、却没有任何信号。
|
|
68
|
+
// 客户端本来就按句切分后再请求,这里超限基本只可能是外部直调:明确回 413 更诚实。
|
|
69
|
+
if (rawText.length > MAX_TEXT) {
|
|
70
|
+
sendJson(res, { ok: false, error: 'text too large', limit: MAX_TEXT }, 413);
|
|
71
|
+
return;
|
|
63
72
|
}
|
|
73
|
+
const msg = rawText;
|
|
74
|
+
const voiceIn = typeof body?.voice === 'string' ? body.voice.trim() : '';
|
|
75
|
+
const isZh = /[\u4e00-\u9fa5]/.test(msg);
|
|
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';
|
|
64
93
|
// 请求体里带了合法 rate 就用它(说书 -15%),没带默认 +0%
|
|
65
94
|
const rate = normalizeRate(body?.rate, '+0%');
|
|
66
95
|
// 音高:允许请求体指定(带情绪的语气),非法值回落到默认
|
|
@@ -75,7 +104,7 @@ export function createTtsHandler(opts) {
|
|
|
75
104
|
if (cache) {
|
|
76
105
|
const hit = await cache.getJson(ck, 'mp3');
|
|
77
106
|
if (hit) {
|
|
78
|
-
sendBuffer(res, hit, 'audio/mpeg',
|
|
107
|
+
sendBuffer(res, hit, 'audio/mpeg', audioHeaders);
|
|
79
108
|
return;
|
|
80
109
|
}
|
|
81
110
|
}
|
|
@@ -90,6 +119,10 @@ export function createTtsHandler(opts) {
|
|
|
90
119
|
if (cloud && cloud.enabled()) {
|
|
91
120
|
try {
|
|
92
121
|
buf = await cloud.synthesize(msg);
|
|
122
|
+
// 云端不接收 voice(用配置里的 speaker),所以「音色被替换」在云端路径上不成立:
|
|
123
|
+
// 成功出云端时把 fallback 头摘掉,别让排障的人以为客户端音色真的生效了(2026-11-07 B93)。
|
|
124
|
+
if (voiceFallback)
|
|
125
|
+
delete audioHeaders['x-tts-voice'];
|
|
93
126
|
}
|
|
94
127
|
catch (ce) {
|
|
95
128
|
console.warn('[tts] CLOUD-FAIL 回退 edge-tts:' + String(ce?.message || ce).slice(0, 200));
|
|
@@ -108,7 +141,7 @@ export function createTtsHandler(opts) {
|
|
|
108
141
|
}
|
|
109
142
|
catch { }
|
|
110
143
|
}
|
|
111
|
-
sendBuffer(res, buf, 'audio/mpeg',
|
|
144
|
+
sendBuffer(res, buf, 'audio/mpeg', audioHeaders);
|
|
112
145
|
}
|
|
113
146
|
catch (err) {
|
|
114
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
|
|
|
@@ -355,3 +355,27 @@ Measure-Command { Invoke-RestMethod -Method Post -Uri http://127.0.0.1:3080/api/
|
|
|
355
355
|
| 4 | 弹琴中提问会同时静琴 | `stopTts()` 原来不停琴 | 弹琴时说一句话:琴声与朗读不叠 |
|
|
356
356
|
| 5 | TTS/ASR 超时现在包含响应体读取 | 以前只到响应头,慢网会永久 pending | 弱网下朗读失败会走降级/重试,而不是卡住 |
|
|
357
357
|
| 6 | 诗名索引失败后会自动重试 | 原来本页永不再试 | 服务端文件缺失后补上,60s 内不刷新也能点名诗 |
|
|
358
|
+
|
|
359
|
+
## 二十、2026-11-07 凌晨批次(B92:1.1.64)待重启核对
|
|
360
|
+
|
|
361
|
+
| # | 改动 | 为什么 | 怎么验证 |
|
|
362
|
+
|---|------|--------|----------|
|
|
363
|
+
| 1 | 192kHz 设备先降采样 | 头写 96k、数据 192k = 半速,ASR 必错 | 高采样率声卡设备上说一句话:识别应正常(不出现整段错乱) |
|
|
364
|
+
| 2 | `/asr` 超限统一 413 | 解码后超限原回 200,监控会记成成功 | 传超长音频:应 413 而不是「成功但空文本」 |
|
|
365
|
+
| 3 | `/asr` 缓存键含 format | 换格式会命中旧格式的识别文本 | 同段音频换格式上传:文本应重新识别 |
|
|
366
|
+
| 4 | `/tts` 超长回 413 | 6001 字静默截断成 6000,听到半句 | 外部直调超长文本:应 413 `text too large` |
|
|
367
|
+
| 5 | 念诗的 `pitch` 真正生效 | 音高选项此前被整段丢弃 | 念诗 vs 普通朗读:音高应不同 |
|
|
368
|
+
| 6 | 纯朗读后状态回 idle | 没开麦时念完显示「在听」 | 关待命→让它读一段:读完状态应回 idle,不是「在听」 |
|
|
369
|
+
| 7 | 纯朗读实例也有看门狗 | 音频不播不报错时永久 `ttsBusy` → 朗读全哑 | 长时间朗读后仍能继续朗读(不必刷新) |
|
|
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
|
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
| `actions/voice.ts` | `registerVoiceActions` |
|
|
83
83
|
| `anim.ts` | `AnimState`、`EYE_L`、`EYE_R`、`MOUTH`、`createAnimator` |
|
|
84
84
|
| `ape.ts` | `APE_DEFAULTS`、`Ape`、`ApeParams`、`ApeStep`、`simulateSegment` |
|
|
85
|
-
| `audio-codec.ts` | `HostAsrFormat`、`RECORDER_MIME_CANDIDATES`、`RecFormat`、`RecorderPick`、`WAV_HEADER_BYTES`、`downmixToMono`、`encodeWavPcm16`、`formatFromMime`、`hostAsrFormat`、`pickRecorderMime`、`wavBytes` |
|
|
85
|
+
| `audio-codec.ts` | `HostAsrFormat`、`RECORDER_MIME_CANDIDATES`、`RecFormat`、`RecorderPick`、`WAV_HEADER_BYTES`、`capSampleRate`、`downmixToMono`、`encodeWavPcm16`、`formatFromMime`、`hostAsrFormat`、`pickRecorderMime`、`wavBytes` |
|
|
86
86
|
| `browser-tts.ts` | `browserVoiceUsable`、`speakViaBrowser`、`watchdogMs` |
|
|
87
87
|
| `bubble.ts` | `BUBBLE_CSS`、`Bubble`、`BubbleOptions`、`STATUS_TEXTS`、`createBubble`、`ensureBubbleStyle` |
|
|
88
88
|
| `busy.ts` | `BUSY_STALE_MS`、`BusyLock`、`Epoch`、`createBusyLock`、`createEpoch` |
|
|
@@ -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` |
|
|
@@ -142,3 +142,9 @@
|
|
|
142
142
|
| B91b | 2026-11-07 | 共享层分身:`deepFreeze` 漏冻子树与 Map/Set/symbol / 噪声先 NFKC 去零宽补全角标点 / 去 6 字重复上限 / 音色白名单大小写不敏感 / 硬超时覆盖响应体;新增 `test-shared-voice.mjs`(89) | 双 tsc 0 / 3671 项 0 失败 | 见 git |
|
|
143
143
|
| B91c | 2026-11-07 | 音频/模拟分身:`sim.isRepeat` 完全相等分支补 `minLen`(第二声「停」不再被吞)/ `melody` resume 1200ms 幂等看门狗;新增 `test-audio-sim.mjs`(169) | 双 tsc 0 / 3671 项 0 失败 | 见 git |
|
|
144
144
|
| B91d | 2026-11-07 | 主代理:`stopTts()` 补 `melody.stop()`(P0 打断同时静琴,扬声器互斥补全另一半) | 双 tsc 0 / 3671 项 0 失败 | 见 git |
|
|
145
|
+
| B92a | 2026-11-07 | 主代理:`capSampleRate` 修「192kHz 设备半速音频」(头与数据同采样率)/ `/tts` 超长改 413(不再静默截断);`test-audio-sim` +6、`test-voice-routes` +2 | 双 tsc 0 / 3799 项 0 失败 | 见 git |
|
|
146
|
+
| B92b | 2026-11-07 | 宿主语音路由分身:`/asr` 解码后超限 200→413 / 缓存键补 `format`(v1→v2);新增 `test-voice-routes.mjs`(23) | 双 tsc 0 / 3799 项 0 失败 | 见 git |
|
|
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-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",
|