dsh-chubby-whale 1.1.62 → 1.1.64

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 CHANGED
@@ -4,6 +4,49 @@
4
4
 
5
5
  ## [未发布]
6
6
 
7
+ ## [1.1.64] - 2026-11-07
8
+
9
+ ### 修复(B92 客户端朗读播放路径分身)
10
+ - **`pitch` 选项完全失效**:`buildTtsJobs` 既不收也不复制 `pitch`,而 `voice.ts` 拼 `/tts` 请求体时读 `job.pitch` → 永远是空串;念诗明明传了 `+15Hz`(音高从未生效)
11
+ - **没开麦克风却显示「在听」**:`pumpTts` 队列空时无条件 `setStatus('listening')`,而 `stopSpeaking` 是 `running ? listening : idle`;试听/说书走 `ensure()` 不开麦,念完状态就变成「在听」
12
+ - **纯朗读实例没有看门狗**:旧实现只在 `start()` 里装 `setInterval`,而 `ensure()` 建的实例从不 `start()` → 音频元素不播也不报错时 `ttsBusy` 永久为真,之后每句只排队(表现「朗读全哑、只能刷新页面」)
13
+ - **诊断计数虚高**:`ttsRequests++` 在冷却分支之前,走系统语音的段也被算成一次 `/tts` 请求
14
+ - **打断整句被 dup 闸门吞**:`isRepeat(minLen:4)` 对 ≥4 字整句生效 —— 说书时连说两遍「停下来吧」「不要说了」,第二声被判重复丢弃;而 `voice-bridge` 的停止正则本应能停。现在打断整句跳过 short/dup 闸门(不去放宽 `sim.ts`,避免削弱短句去重)
15
+
16
+ ### 修复(B92 宿主语音路由分身)
17
+ - **`/asr` 解码后超限回 200**:同一路由「请求体超限」走 413、「解码后超限」却回 200,代理/日志会把超限记成识别成功;统一 413
18
+ - **`/asr` 缓存键缺 `format`**:同一份字节先以 `wav` 识别、再以 `webm` 上传会直接命中旧文本(实测 `fetch=1`、`cached:true`);键升 v2 并加入 format,旧条目自然失效
19
+
20
+ ### 修复(主代理)
21
+ - **设备 192kHz 导致半速音频**:`AudioContext.sampleRate` 跟随输出设备,192k 设备上数据是 192k 而 WAV 头被夹到 96k → ASR 整段识别必错。新增 `capSampleRate`(整数倍块平均)先降采样,**头与数据同采样率**
22
+ - **`/tts` 静默截断**:6001 字回 HTTP 200 但引擎只收到 6000 字(听到半句、无任何信号);改为 413 `{error:'text too large', limit:6000}`,正好 6000 仍正常
23
+
24
+ ### 测试
25
+ - 新增 `test-voice-routes.mjs`(23)、`test-voice-tts.mjs`(99),接入 `npm test`;`test-audio-sim` 补 6 条采样率断言
26
+ - 全链 **3799 项 0 失败**(`node tools/test-count.mjs`:52 个套件,失败 0,未识别 0)
27
+
28
+ ## [1.1.63] - 2026-11-07
29
+
30
+ ### 修复(B91 共享层与语音子模块分身)
31
+ - **`deepFreeze` 漏冻子树**:父对象已冻结时直接 return,子对象不再处理(`deepFreeze(Object.freeze({child}))` 后 child 仍可变);Map 的键/值、Set 的元素、symbol 键上的值全都没冻。改为 WeakSet 遍历 + 容器/Map/Set 递归
32
+ - **半角句号的打断词掐不掉朗读**:标点表缺 `。`(U+FF61) 且归一化没做 NFKC → `isInterruptWord('停。')===false`(「停。」是真);零宽字符同症。现在先 NFKC → 去零宽 → 去空白标点 → 小写
33
+ - **同一个字重复 7 次以上反而不判噪声**:`length<=6` 上限让「啊啊啊啊啊啊啊啊」被当成真话送模型(越像气口越危险);上限去掉
34
+ - **音色白名单大小写敏感 + `String()` 无兜底**:`EN-US-ANANEURAL` 被静默重置成晓伊,`Object.create(null)` 直接抛 TypeError
35
+ - **硬超时只覆盖到响应头**:`finally` 里清定时器/摘监听,响应体永挂时 `await resp.arrayBuffer()` 永远 pending(无 AbortError)。现在超时/外部中断覆盖响应体读取阶段
36
+
37
+ ### 修复(B91 音频与模拟分身)
38
+ - **8 秒内第二声「停/继续」被当重复吞掉**:`sim.isRepeat` 的「完全相等」分支绕过了 `minLen` 门槛(②③ 都有、① 没有)→ 喊不停说书、短命令重复请求被吞
39
+ - **琴声 onDone 永久丢失**:`resume()` 在自动播放被拦时可能既不 resolve 也不 reject,`p.then(schedule, schedule)` 是唯一通路 → 不排程、`onDone` 不来(「弹完啦」气泡与朗读都不出)。加 1200ms 幂等看门狗,stop/换曲一并撤
40
+
41
+ ### 修复(主代理)
42
+ - **诗名索引「本页永不再试」**:`started` 只置一次,失败后本页永不重试;而宿主是「文件补上就能恢复」。现在 60s 退避、最多 3 次,并加**在途闸**(`detectIntent` 同步循环连调也只发一个请求)
43
+ - **`parseGrowth` 接受假值**:`{ok:'false'}`、`{ok:1}` 被当真,`{exp:null}`→0、`{exp:true}`→1;现在 `ok` 必须严格 `true`,`exp` 只认数字或非空数字串
44
+ - **P0 打断不同时静琴**:`stopTts()` 只停朗读/说书,弹琴中提问会两路同时出声(违反并发纪律 §二);补 `melody.stop()`
45
+
46
+ ### 测试
47
+ - 新增 `test-shared-voice.mjs`(89)、`test-audio-sim.mjs`(169)、`test-poem-titles-retry.mjs`(10),接入 `npm test`;`test-utils-pure` 补 3 条 look 断言
48
+ - 全链 **3671 项 0 失败**(`node tools/test-count.mjs`:50 个套件,失败 0,未识别 0)
49
+
7
50
  ## [1.1.62] - 2026-11-07
8
51
 
9
52
  ### 修复(B90 人格与云端语音分身)
package/dist/client.js CHANGED
@@ -1,20 +1,31 @@
1
1
  (() => {
2
2
  // src/shared/immutable.ts
3
- function deepFreeze(value) {
3
+ function freezeInto(value, seen) {
4
4
  if (value === null || typeof value !== "object") return value;
5
- const obj = value;
6
- if (Object.isFrozen(obj)) return value;
7
- Object.freeze(obj);
8
- if (Array.isArray(obj)) {
9
- for (const v of obj) deepFreeze(v);
10
- } else if (Object.prototype.toString.call(obj) === "[object Object]") {
11
- for (const k of Object.keys(obj)) deepFreeze(obj[k]);
5
+ if (seen.has(value)) return value;
6
+ seen.add(value);
7
+ const tag = Object.prototype.toString.call(value);
8
+ Object.freeze(value);
9
+ if (Array.isArray(value)) {
10
+ for (let i = 0; i < value.length; i++) freezeInto(value[i], seen);
11
+ } else if (tag === "[object Map]") {
12
+ for (const [k, v] of value) {
13
+ freezeInto(k, seen);
14
+ freezeInto(v, seen);
15
+ }
16
+ } else if (tag === "[object Set]") {
17
+ for (const v of value) freezeInto(v, seen);
18
+ } else if (tag === "[object Object]") {
19
+ for (const k of Reflect.ownKeys(value)) freezeInto(value[k], seen);
12
20
  }
13
21
  return value;
14
22
  }
23
+ function deepFreeze(value) {
24
+ return freezeInto(value, /* @__PURE__ */ new WeakSet());
25
+ }
15
26
 
16
27
  // src/client/version.ts
17
- var VERSION = "1.1.62";
28
+ var VERSION = "1.1.64";
18
29
 
19
30
  // src/client/styles.ts
20
31
  var W = 240;
@@ -788,6 +799,29 @@
788
799
  }
789
800
  return buf;
790
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
+ }
791
825
  function wavBytes(seconds, sampleRate) {
792
826
  const sec = Math.max(0, Number(seconds) || 0);
793
827
  const sr = Math.max(8e3, Math.min(96e3, Math.round(Number(sampleRate) || 48e3)));
@@ -996,11 +1030,14 @@
996
1030
  const n = Math.min(2, Number(buf.numberOfChannels) || 1);
997
1031
  for (let i = 0; i < n; i++) chans.push(buf.getChannelData(i));
998
1032
  const mono = downmixToMono(chans);
999
- if (wavBytes(mono.length / (Number(buf.sampleRate) || 48e3), buf.sampleRate) > maxWavBytes) {
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) {
1000
1037
  done(null);
1001
1038
  return;
1002
1039
  }
1003
- done(new Blob([encodeWavPcm16(mono, buf.sampleRate)], { type: "audio/wav" }));
1040
+ done(new Blob([encodeWavPcm16(samples, rate)], { type: "audio/wav" }));
1004
1041
  } catch (e) {
1005
1042
  done(null);
1006
1043
  }
@@ -1085,7 +1122,7 @@
1085
1122
  var INTERRUPT_WORDS = ["停", "别", "不", "闭嘴", "打住"];
1086
1123
  deepFreeze(INTERRUPT_WORDS);
1087
1124
  function normalizeNoiseKey(text) {
1088
- return String(text == null ? "" : text).replace(/\s+/g, "").replace(/[。,、!?;:,.!?;:…~~·""''()()【】\[\]]/g, "").toLowerCase();
1125
+ return String(text == null ? "" : text).normalize("NFKC").replace(/[\u200b-\u200f\u2060\ufeff]/g, "").replace(/\s+/g, "").replace(/[。,、!?;:,.!?;:…~~·""''()()【】\[\]「」『』《》〈〉"'`]/g, "").toLowerCase();
1089
1126
  }
1090
1127
  function isNoiseText(text) {
1091
1128
  const k = normalizeNoiseKey(text);
@@ -1093,7 +1130,7 @@
1093
1130
  if (isInterruptWord(k)) return false;
1094
1131
  if (k.length <= 1) return true;
1095
1132
  if (NOISE_TEXTS.indexOf(k) >= 0) return true;
1096
- if (k.length <= 6 && /^([\u4e00-\u9fa5])\1{2,}$/.test(k)) return true;
1133
+ if (/^([\u4e00-\u9fa5])\1{2,}$/.test(k)) return true;
1097
1134
  return false;
1098
1135
  }
1099
1136
  function isInterruptWord(text) {
@@ -1136,7 +1173,7 @@
1136
1173
  if (now - Number(item2 && item2.at || 0) > windowMs) continue;
1137
1174
  const prev = normalizeText(item2 && item2.text ? item2.text : "");
1138
1175
  if (!prev) continue;
1139
- if (cur === prev) return true;
1176
+ if (cur === prev && cur.length >= minLen) return true;
1140
1177
  const shortOne = cur.length <= prev.length ? cur : prev;
1141
1178
  const longOne = cur.length <= prev.length ? prev : cur;
1142
1179
  if (shortOne.length >= minLen && longOne.indexOf(shortOne) >= 0) return true;
@@ -1146,6 +1183,31 @@
1146
1183
  }
1147
1184
 
1148
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
+ }
1149
1211
  function createEchoFilter() {
1150
1212
  const spokenRecent = [];
1151
1213
  const recentHeard = [];
@@ -1167,7 +1229,8 @@
1167
1229
  }
1168
1230
  const plain = normalizePlain(text).toLowerCase();
1169
1231
  const plainKey = echoKey(text).toLowerCase();
1170
- const interrupt = /^[停别不]$/.test(plain);
1232
+ const interrupt = SINGLE_INTERRUPT.test(plain);
1233
+ const stopCmd = isInterruptCommand(plain);
1171
1234
  if (plain.length < 2 && !interrupt) return { ok: false, reason: "trivial" };
1172
1235
  if (!interrupt && isNoiseText(plain)) return { ok: false, reason: "noise" };
1173
1236
  if (/^本鱼在听/.test(plain)) return { ok: false, reason: "self" };
@@ -1187,8 +1250,8 @@
1187
1250
  if (plain.length >= 6 && target.indexOf(plain.slice(0, 6)) >= 0) return { ok: false, reason: "echo" };
1188
1251
  if (plain.length >= 3 && now - s.at < 6e3 && target.indexOf(plain) >= 0) return { ok: false, reason: "echo" };
1189
1252
  }
1190
- if (isShortEchoFragment(plain, hadRecentSpeech)) return { ok: false, reason: "short" };
1191
- 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 })) {
1192
1255
  return { ok: false, reason: "dup" };
1193
1256
  }
1194
1257
  recentHeard.push({ text, at: now });
@@ -1210,13 +1273,6 @@
1210
1273
  // src/client/voice/fetch.ts
1211
1274
  async function fetchWithTimeout(url, init, ms2, outer) {
1212
1275
  const ctl = typeof AbortController !== "undefined" ? new AbortController() : null;
1213
- const timer = ctl ? setTimeout(() => {
1214
- try {
1215
- ctl.abort();
1216
- } catch (e) {
1217
- }
1218
- }, ms2) : null;
1219
- let sig = ctl ? ctl.signal : void 0;
1220
1276
  const forwardOuter = () => {
1221
1277
  try {
1222
1278
  if (ctl) ctl.abort();
@@ -1224,6 +1280,22 @@
1224
1280
  }
1225
1281
  };
1226
1282
  let hooked = false;
1283
+ const unhook = () => {
1284
+ if (!hooked) return;
1285
+ hooked = false;
1286
+ try {
1287
+ if (outer) outer.removeEventListener("abort", forwardOuter);
1288
+ } catch (e) {
1289
+ }
1290
+ };
1291
+ const timer = ctl ? setTimeout(() => {
1292
+ unhook();
1293
+ try {
1294
+ ctl.abort();
1295
+ } catch (e) {
1296
+ }
1297
+ }, ms2) : null;
1298
+ let sig = ctl ? ctl.signal : void 0;
1227
1299
  if (outer && ctl) {
1228
1300
  const AnySig = AbortSignal;
1229
1301
  if (typeof AnySig.any === "function") {
@@ -1247,15 +1319,21 @@
1247
1319
  sig = outer;
1248
1320
  }
1249
1321
  try {
1250
- return await fetch(url, sig ? { ...init, signal: sig } : init);
1251
- } finally {
1252
- if (timer) clearTimeout(timer);
1253
- if (hooked) {
1322
+ const resp = await fetch(url, sig ? { ...init, signal: sig } : init);
1323
+ try {
1324
+ if (timer && typeof timer.unref === "function") timer.unref();
1325
+ } catch (e) {
1326
+ }
1327
+ return resp;
1328
+ } catch (e) {
1329
+ unhook();
1330
+ if (timer) {
1254
1331
  try {
1255
- outer.removeEventListener("abort", forwardOuter);
1256
- } catch (e) {
1332
+ clearTimeout(timer);
1333
+ } catch (e2) {
1257
1334
  }
1258
1335
  }
1336
+ throw e;
1259
1337
  }
1260
1338
  }
1261
1339
 
@@ -1273,9 +1351,21 @@
1273
1351
  ];
1274
1352
  deepFreeze(TTS_VOICES);
1275
1353
  var DEFAULT_VOICE = "zh-CN-XiaoyiNeural";
1354
+ var VOICE_BY_KEY = (() => {
1355
+ const m = /* @__PURE__ */ new Map();
1356
+ for (const v of TTS_VOICES) m.set(v.toLowerCase(), v);
1357
+ return m;
1358
+ })();
1276
1359
  function normalizeVoiceName(name, fallback = DEFAULT_VOICE) {
1277
- const n = str(name).trim();
1278
- return TTS_VOICES.indexOf(n) >= 0 ? n : fallback;
1360
+ let n = "";
1361
+ try {
1362
+ n = str(name).trim();
1363
+ } catch (e) {
1364
+ return fallback;
1365
+ }
1366
+ if (!n) return fallback;
1367
+ const hit = VOICE_BY_KEY.get(n.toLowerCase());
1368
+ return hit || fallback;
1279
1369
  }
1280
1370
  function readStoredVoiceName() {
1281
1371
  try {
@@ -1360,10 +1450,10 @@
1360
1450
  if (/[,、;,;]/.test(last)) return 140;
1361
1451
  return 200;
1362
1452
  }
1363
- function prefetchTargets(queue, narration, inflight = 0, has, maxInflight = 3) {
1453
+ function prefetchTargets(queue, narration, inflight2 = 0, has, maxInflight = 3) {
1364
1454
  const depth = narration ? 3 : 2;
1365
1455
  const cap = Math.max(0, Math.floor(Number(maxInflight) || 0));
1366
- let n = Math.max(0, Math.floor(Number(inflight) || 0));
1456
+ let n = Math.max(0, Math.floor(Number(inflight2) || 0));
1367
1457
  const out = [];
1368
1458
  for (let i = 0; i < depth && i < queue.length; i++) {
1369
1459
  const t = queue[i];
@@ -1376,11 +1466,13 @@
1376
1466
  }
1377
1467
  function buildTtsJobs(segs, opts) {
1378
1468
  const out = [];
1469
+ const pitch = opts.pitch ? String(opts.pitch) : void 0;
1379
1470
  for (let i = 0; i < segs.length; i++) {
1380
1471
  out.push({
1381
1472
  seq: opts.nextSeq + i,
1382
1473
  text: segs[i],
1383
1474
  rate: opts.rate,
1475
+ pitch,
1384
1476
  narration: opts.narration,
1385
1477
  attempts: 0,
1386
1478
  strict: opts.strict,
@@ -1954,6 +2046,10 @@
1954
2046
  } catch (e) {
1955
2047
  }
1956
2048
  }
2049
+ function ensureTtsWatch() {
2050
+ if (ttsWatch) return;
2051
+ ttsWatch = setInterval(ttsWatchdog, TTS_WATCH_MS);
2052
+ }
1957
2053
  function ttsWatchdog() {
1958
2054
  try {
1959
2055
  const audioPlaying = !!(ttsAudio && !ttsAudio.paused && !ttsAudio.ended && ttsAudio.readyState > 0);
@@ -2109,6 +2205,7 @@
2109
2205
  }
2110
2206
  }
2111
2207
  function pumpTts(force) {
2208
+ ensureTtsWatch();
2112
2209
  if (ttsBusy) return;
2113
2210
  if (ttsNextRetryAt > 0) {
2114
2211
  if (!force && Date.now() < ttsNextRetryAt) return;
@@ -2116,7 +2213,7 @@
2116
2213
  }
2117
2214
  const job = ttsQ.shift();
2118
2215
  if (!job) {
2119
- setStatus("listening");
2216
+ setStatus(running ? "listening" : "idle");
2120
2217
  return;
2121
2218
  }
2122
2219
  ttsBusy = true;
@@ -2126,7 +2223,6 @@
2126
2223
  const myToken = ++speakToken;
2127
2224
  job.token = myToken;
2128
2225
  setStatus("speaking");
2129
- stats.ttsRequests++;
2130
2226
  if (ttsInCooldown() && job.attempts <= 0) {
2131
2227
  ttsSynthAt = 0;
2132
2228
  ttsDiag(job, true, "cooldown");
@@ -2137,6 +2233,7 @@
2137
2233
  ttsBrowserFallback(job, myToken);
2138
2234
  return;
2139
2235
  }
2236
+ stats.ttsRequests++;
2140
2237
  const cached = ttsPrefetch.get(job.seq);
2141
2238
  const cur = cached || synth(job);
2142
2239
  if (cached) {
@@ -2452,6 +2549,8 @@
2452
2549
  echoFilter.noteSpoken(raw);
2453
2550
  const jobs = buildTtsJobs(segs, {
2454
2551
  rate,
2552
+ // 音高一路传到 /tts 的请求体:不在这里交给队列任务,宿主永远只收到空串(B92 修)
2553
+ pitch: o && o.pitch,
2455
2554
  narration,
2456
2555
  strict,
2457
2556
  nextSeq: ttsSeq + 1,
@@ -3246,7 +3345,8 @@
3246
3345
  var DEFAULT_GROWTH = { exp: 0, stage: "初识", nextExp: 200, tier: 0 };
3247
3346
  deepFreeze(DEFAULT_GROWTH);
3248
3347
  function parseGrowth(d) {
3249
- if (!d || typeof d !== "object" || !d.ok) return null;
3348
+ if (!d || typeof d !== "object" || d.ok !== true) return null;
3349
+ if (typeof d.exp !== "number" && !(typeof d.exp === "string" && d.exp.trim() !== "")) return null;
3250
3350
  const raw = Number(d.exp);
3251
3351
  if (!isFinite(raw)) return null;
3252
3352
  const exp = Math.max(0, Math.floor(raw));
@@ -4242,6 +4342,7 @@
4242
4342
  }
4243
4343
 
4244
4344
  // src/client/melody.ts
4345
+ var RESUME_GUARD_MS = 1200;
4245
4346
  var NOTE_FREQ = {
4246
4347
  "C3": 130.81,
4247
4348
  "C#3": 138.59,
@@ -4301,6 +4402,7 @@
4301
4402
  let ctx = null;
4302
4403
  let oscs = [];
4303
4404
  let doneTimer = null;
4405
+ let resumeTimer = null;
4304
4406
  let seq = 0;
4305
4407
  function ensureCtx() {
4306
4408
  try {
@@ -4322,6 +4424,8 @@
4322
4424
  oscs = [];
4323
4425
  clearT(doneTimer);
4324
4426
  doneTimer = null;
4427
+ clearT(resumeTimer);
4428
+ resumeTimer = null;
4325
4429
  }
4326
4430
  function stop() {
4327
4431
  seq++;
@@ -4387,12 +4491,22 @@
4387
4491
  } catch (e) {
4388
4492
  }
4389
4493
  };
4494
+ let ran = false;
4495
+ const runOnce = () => {
4496
+ if (ran) return;
4497
+ ran = true;
4498
+ clearT(resumeTimer);
4499
+ resumeTimer = null;
4500
+ schedule();
4501
+ };
4390
4502
  try {
4391
4503
  const p = typeof c.resume === "function" ? c.resume() : null;
4392
- if (p && typeof p.then === "function") p.then(schedule, schedule);
4393
- else schedule();
4504
+ if (p && typeof p.then === "function") {
4505
+ p.then(runOnce, runOnce);
4506
+ resumeTimer = setTimeout(runOnce, RESUME_GUARD_MS);
4507
+ } else runOnce();
4394
4508
  } catch (e) {
4395
- schedule();
4509
+ runOnce();
4396
4510
  }
4397
4511
  } catch (e) {
4398
4512
  }
@@ -6817,6 +6931,11 @@
6817
6931
  var FETCH_TIMEOUT_MS = 8e3;
6818
6932
  var cache = deepFreeze([]);
6819
6933
  var started = false;
6934
+ var RETRY_DELAY_MS = 60 * 1e3;
6935
+ var MAX_ATTEMPTS = 3;
6936
+ var attempts = 0;
6937
+ var nextTryAt = 0;
6938
+ var inflight = false;
6820
6939
  function normalize(list) {
6821
6940
  const out = [];
6822
6941
  if (!Array.isArray(list)) return out;
@@ -6880,11 +6999,26 @@
6880
6999
  return cache;
6881
7000
  }
6882
7001
  function ensurePoemTitles() {
6883
- if (started || cache.length > 0) return;
7002
+ if (cache.length > 0) return;
7003
+ if (inflight) return;
7004
+ if (attempts >= MAX_ATTEMPTS) return;
7005
+ if (Date.now() < nextTryAt) return;
6884
7006
  started = true;
7007
+ attempts++;
7008
+ inflight = true;
7009
+ const failed = () => {
7010
+ inflight = false;
7011
+ nextTryAt = Date.now() + RETRY_DELAY_MS;
7012
+ };
6885
7013
  getJson(API_PATH, { signal: timeoutSignal(FETCH_TIMEOUT_MS) }).then((data) => {
6886
- if (data && data.ok === true) apply(data.titles, true);
7014
+ if (data && data.ok === true && Array.isArray(data.titles) && data.titles.length > 0) {
7015
+ inflight = false;
7016
+ apply(data.titles, true);
7017
+ return;
7018
+ }
7019
+ failed();
6887
7020
  }).catch(() => {
7021
+ failed();
6888
7022
  });
6889
7023
  }
6890
7024
  {
@@ -9475,6 +9609,10 @@
9475
9609
  }
9476
9610
  resetNarration();
9477
9611
  bridge.stopSpeak();
9612
+ try {
9613
+ melody.stop();
9614
+ } catch (e) {
9615
+ }
9478
9616
  try {
9479
9617
  storyBusyRef.current = false;
9480
9618
  } catch (e) {
@@ -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;外呼超时 60 秒
5
+ * 体积:请求体(base64)上限 12MB → 413;解码后音频上限 8MB → 同样 413(口径统一,2026-11-07)
6
+ * 外呼超时 60 秒
6
7
  */
7
8
  import { hashBytes } from '../../shared/hash.js';
8
9
  import { isInterruptWord, isNoiseText } from '../../shared/noise.js';
@@ -49,9 +50,13 @@ export function createAsrHandler(opts) {
49
50
  return;
50
51
  }
51
52
  const buf = Buffer.from(audioB64, 'base64');
52
- // 解码后再判音频上限(8MB)
53
+ // 解码后再判音频上限(8MB)。
54
+ // 必须与上面「请求体超限」同一口径回 413:同一个路由对「太大」给出 200/413 两种状态码,
55
+ // 客户端虽只看 ok 字段、表现不变,但代理、网关、访问日志只认状态码——200 会被记成
56
+ // 「识别成功」,排障时查不出来(2026-11-07 B92 实测:8MB+1 字节走的正是这条分支)。
53
57
  if (buf.length > ASR_MAX_AUDIO) {
54
- sendJson(res, { ok: false, error: 'audio too large', text: '' });
58
+ console.error('[asr] AUDIO-TOO-LARGE ' + buf.length + ' bytes > ' + ASR_MAX_AUDIO);
59
+ sendJson(res, { ok: false, error: 'audio too large', text: '' }, 413);
55
60
  return;
56
61
  }
57
62
  const key = opts.getKey();
@@ -61,7 +66,12 @@ export function createAsrHandler(opts) {
61
66
  }
62
67
  // 用完整音频字节做 key(只取前 4000 字符容易撞 key)
63
68
  // key 里带模型名:换 ASR 模型后不会命中旧文本
64
- const ck = hashBytes(Buffer.concat([Buffer.from('v1|' + ASR_MODEL + '|'), buf]));
69
+ // key 里还必须带 format:同一份字节在 multipart 里声明的 Content-Type / filename 不同,
70
+ // 上游的解码方式就不同。原来只按「模型 + 字节」缓存,实测同一份字节先用 format=wav
71
+ // 识别成功,再以 format=webm 上传会直接命中上一次的文本(cached:true)——A 容器的结果
72
+ // 顶了 B 容器的请求。v1 前缀的旧条目没有 format,刻意升到 v2 让它们自然失效(7 天 TTL 内
73
+ // 最多少几次命中,不会读出别人的文本)。
74
+ const ck = hashBytes(Buffer.concat([Buffer.from('v2|' + ASR_MODEL + '|' + format + '|'), buf]));
65
75
  if (opts.cache) {
66
76
  const hit = await opts.cache.getJson(ck, 'json', ASR_CACHE_MAX_AGE);
67
77
  if (hit && typeof hit.text === 'string') {
@@ -54,7 +54,15 @@ export function createTtsHandler(opts) {
54
54
  return;
55
55
  }
56
56
  const body = parsed.body;
57
- const msg = typeof body?.text === 'string' ? body.text.trim().slice(0, MAX_TEXT) : '';
57
+ const rawText = typeof body?.text === 'string' ? body.text.trim() : '';
58
+ // 超长不再静默截断(2026-11-07 B92 审计):原来 slice(0, MAX_TEXT) 会回 200,
59
+ // 但引擎只收到前 6000 字 —— 调用方听到的是半句话、却没有任何信号。
60
+ // 客户端本来就按句切分后再请求,这里超限基本只可能是外部直调:明确回 413 更诚实。
61
+ if (rawText.length > MAX_TEXT) {
62
+ sendJson(res, { ok: false, error: 'text too large', limit: MAX_TEXT }, 413);
63
+ return;
64
+ }
65
+ const msg = rawText;
58
66
  const voiceIn = typeof body?.voice === 'string' ? body.voice : '';
59
67
  const isZh = /[\u4e00-\u9fa5]/.test(msg);
60
68
  let voice = (voiceIn && /^[a-zA-Z0-9-]+$/.test(voiceIn)) ? voiceIn : '';
@@ -3,22 +3,45 @@
3
3
  *
4
4
  * 用法:`export const CONFIG = {...}` 声明后紧跟 `deepFreeze(CONFIG)`。
5
5
  * 冻结后调用方 push/splice/赋值都会在严格模式下报错,等于把可变数据挡在模块外。
6
+ *
7
+ * 边界(2026-11-07 逐项核对,不是想当然):
8
+ * · 循环引用 / 同一对象出现多次:用 WeakSet 只走一遍,不会爆栈;
9
+ * · 父对象已被 Object.freeze、子对象还没冻:照样递归到底
10
+ * —— 旧实现见"已冻结就 return",这种父对象会把整棵子树漏掉(本次修的就是这条);
11
+ * · 数组 / 普通对象 / Map / Set 的键值元素都递归;普通对象连 symbol 键也走(Reflect.ownKeys);
12
+ * · Map/Set 只能"冻结容器 + 冻结里面的键值":set()/add() 动的是内部槽,
13
+ * V8 没有钩子拦得住,所以**真正要锁死的容器别导出**,导出冻结的数组/记录更稳;
14
+ * · TypedArray(带元素)冻结会抛 TypeError,这是 JS 的限制,本函数不吞错、不假装成功。
6
15
  */
7
- export function deepFreeze(value) {
16
+ /** 递归冻结的实体(WeakSet 同时解决"循环引用"和"同一对象重复出现") */
17
+ function freezeInto(value, seen) {
8
18
  if (value === null || typeof value !== 'object')
9
19
  return value;
10
- const obj = value;
11
- if (Object.isFrozen(obj))
20
+ if (seen.has(value))
12
21
  return value;
13
- Object.freeze(obj);
14
- // 只在还能安全枚举时递归(数组/普通对象;函数与 RegExp 直接跳过)
15
- if (Array.isArray(obj)) {
16
- for (const v of obj)
17
- deepFreeze(v);
22
+ seen.add(value);
23
+ const tag = Object.prototype.toString.call(value);
24
+ Object.freeze(value);
25
+ if (Array.isArray(value)) {
26
+ for (let i = 0; i < value.length; i++)
27
+ freezeInto(value[i], seen);
28
+ }
29
+ else if (tag === '[object Map]') {
30
+ for (const [k, v] of value) {
31
+ freezeInto(k, seen);
32
+ freezeInto(v, seen);
33
+ }
18
34
  }
19
- else if (Object.prototype.toString.call(obj) === '[object Object]') {
20
- for (const k of Object.keys(obj))
21
- deepFreeze(obj[k]);
35
+ else if (tag === '[object Set]') {
36
+ for (const v of value)
37
+ freezeInto(v, seen);
38
+ }
39
+ else if (tag === '[object Object]') {
40
+ for (const k of Reflect.ownKeys(value))
41
+ freezeInto(value[k], seen);
22
42
  }
23
43
  return value;
24
44
  }
45
+ export function deepFreeze(value) {
46
+ return freezeInto(value, new WeakSet());
47
+ }
@@ -20,11 +20,21 @@ deepFreeze(NOISE_TEXTS);
20
20
  */
21
21
  export const INTERRUPT_WORDS = ['停', '别', '不', '闭嘴', '打住'];
22
22
  deepFreeze(INTERRUPT_WORDS);
23
- /** 归一化:去空白与标点、统一小写 */
23
+ /**
24
+ * 归一化:NFKC(全角→半角、兼容字符折叠)→ 去零宽 → 去空白与标点 → 统一小写。
25
+ *
26
+ * 为什么要 NFKC(2026-11-07 修):识别服务会吐半角片假名标点,`。`(U+FF61)不在中文
27
+ * 标点表里,于是「嗯。」归一化后还剩两个字 → 单字规则失效、被当成真话送进模型;
28
+ * 「停。」更糟:认不出是打断词,喊停掐不掉朗读。NFKC 把 。 → 。、" → "、A → A、
29
+ * ~ → ~,再统一小写,全角脏值才不会各说各话。
30
+ * 零宽字符(\u200b-\u200f / \u2060 / BOM)同理:不删就会把单字撑成两个字。
31
+ */
24
32
  export function normalizeNoiseKey(text) {
25
33
  return String(text == null ? '' : text)
34
+ .normalize('NFKC')
35
+ .replace(/[\u200b-\u200f\u2060\ufeff]/g, '')
26
36
  .replace(/\s+/g, '')
27
- .replace(/[。,、!?;:,.!?;:…~~·""''()()【】\[\]]/g, '')
37
+ .replace(/[。,、!?;:,.!?;:…~~·""''()()【】\[\]「」『』《》〈〉"'`]/g, '')
28
38
  .toLowerCase();
29
39
  }
30
40
  /**
@@ -33,7 +43,8 @@ export function normalizeNoiseKey(text) {
33
43
  * ⓪ 打断词(停/别/不/闭嘴/打住)先放行——它们和单字气口同形但语义相反
34
44
  * ① 归一化后为空,或只有一个字符("嗯"「啊」这类气口)
35
45
  * ② 命中噪声词表
36
- * ③ 同一个字重复 ≥3 次且整句很短("嗯嗯嗯嗯")
46
+ * ③ 整句只有一个字重复 ≥3 次("嗯嗯嗯…"):不设总长上限
47
+ * —— 旧实现卡在 length<=6,"啊"重复 7 次反而逃脱,越像气口越判成真话,是反直觉的漏洞
37
48
  */
38
49
  export function isNoiseText(text) {
39
50
  const k = normalizeNoiseKey(text);
@@ -45,7 +56,7 @@ export function isNoiseText(text) {
45
56
  return true;
46
57
  if (NOISE_TEXTS.indexOf(k) >= 0)
47
58
  return true;
48
- if (k.length <= 6 && /^([\u4e00-\u9fa5])\1{2,}$/.test(k))
59
+ if (/^([\u4e00-\u9fa5])\1{2,}$/.test(k))
49
60
  return true;
50
61
  return false;
51
62
  }
@@ -12,14 +12,14 @@
12
12
 
13
13
  | 项 | 值 |
14
14
  | --- | --- |
15
- | `package.json` version | 1.1.62 |
16
- | `src/client/version.ts` VERSION | 1.1.62(由 `scripts/gen-version.mjs` 生成) |
17
- | 最近 Gitee tag | `v1.1.62`(见 `git tag --list`) |
15
+ | `package.json` version | 1.1.64 |
16
+ | `src/client/version.ts` VERSION | 1.1.64(由 `scripts/gen-version.mjs` 生成) |
17
+ | 最近 Gitee tag | `v1.1.64`(见 `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.62**(发布后轮询确认;202 受理到可见约 2–4 分钟) |
20
+ | npm latest | **1.1.64**(发布后轮询确认) |
21
21
  | README 版本 | 无硬编码版本号 |
22
- | `npm test` | **3400 项**(47 个套件,`node tools/test-count.mjs` 核准、失败 0) |
22
+ | `npm test` | **3799 项**(52 个套件,`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
 
@@ -344,3 +344,27 @@ Measure-Command { Invoke-RestMethod -Method Post -Uri http://127.0.0.1:3080/api/
344
344
  1. 菜单版本显示 **1.1.62**;
345
345
  2. 超大文件导入提示「太大」而不是网络错误;
346
346
  3. 说「换个说法」不会被念诗打断。
347
+
348
+ ## 十九、2026-11-07 凌晨批次(B91:1.1.63)待重启核对
349
+
350
+ | # | 改动 | 为什么 | 怎么验证 |
351
+ |---|------|--------|----------|
352
+ | 1 | 半角句号/零宽字符也能识别打断词 | 「停。」以前掐不掉朗读 | 朗读中喊「停」:立刻停 |
353
+ | 2 | 同一个字重复 7 次以上判噪声 | 「啊啊啊啊啊啊啊啊」以前被当真话送模型 | 对着麦克风拖长「啊」:不应触发送模型 |
354
+ | 3 | 8 秒内第二声「停/继续」不再被吞 | `sim.isRepeat` 绕过 `minLen` | 8 秒内连说两次「停」:第二次也要生效 |
355
+ | 4 | 弹琴中提问会同时静琴 | `stopTts()` 原来不停琴 | 弹琴时说一句话:琴声与朗读不叠 |
356
+ | 5 | TTS/ASR 超时现在包含响应体读取 | 以前只到响应头,慢网会永久 pending | 弱网下朗读失败会走降级/重试,而不是卡住 |
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 闸门吞 | 说书中连说两次「停下来吧」:第二声也应停 |
@@ -5,7 +5,7 @@
5
5
  > 架构宪章:**模块只对外暴露这些导出,外部不得读取模块内部实现**。
6
6
  > 新增/修改导出 = 接口变更,必须同步调用点并在提交信息里写明。
7
7
 
8
- > 统计:119 个模块文件,717 个导出项。
8
+ > 统计:119 个模块文件,718 个导出项。
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` |
@@ -138,3 +138,10 @@
138
138
  | B90b | 2026-11-07 | 人格与云端语音分身:阶段语气防原型链 / 云端响应 16MB 上限(声明·流式·错误页)/ 记账写盘失败不再 fail-open(进程内兜底);新增 `test-persona-cloud.mjs`(139+4 变异体) | 双 tsc 0 / 3400 项 0 失败 | 见 git |
139
139
  | B90c | 2026-11-07 | 客户端粘合分身:点播劫持追问 / 句末标点整句失效 / 典故成语播报反了 / APE 判停 NaN 与 -160 哨兵 / `steadyWindowFrames` 写死 8 / 气泡 trim / `useLazyRef` 假值重建;新增 `test-client-glue.mjs`(113+6 变异体) | 双 tsc 0 / 3400 项 0 失败 | 见 git |
140
140
  | B90d | 2026-11-07 | 主代理:客户端情绪定时器加 dispose 与 holdMs 夹紧 / 碗数负数归零 / `pet.setMood` 情绪白名单 | 双 tsc 0 / 3400 项 0 失败 | 见 git |
141
+ | B91a | 2026-11-07 | 主代理:`parseGrowth` 严格 `ok===true` 且 `exp` 只认数字/数字串 / 诗名索引 60s×3 有界重试 + 在途闸;新增 `test-poem-titles-retry.mjs`(10)、`test-utils-pure` +3 | 双 tsc 0 / 3671 项 0 失败 | 见 git |
142
+ | B91b | 2026-11-07 | 共享层分身:`deepFreeze` 漏冻子树与 Map/Set/symbol / 噪声先 NFKC 去零宽补全角标点 / 去 6 字重复上限 / 音色白名单大小写不敏感 / 硬超时覆盖响应体;新增 `test-shared-voice.mjs`(89) | 双 tsc 0 / 3671 项 0 失败 | 见 git |
143
+ | B91c | 2026-11-07 | 音频/模拟分身:`sim.isRepeat` 完全相等分支补 `minLen`(第二声「停」不再被吞)/ `melody` resume 1200ms 幂等看门狗;新增 `test-audio-sim.mjs`(169) | 双 tsc 0 / 3671 项 0 失败 | 见 git |
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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-chubby-whale",
3
- "version": "1.1.62",
3
+ "version": "1.1.64",
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-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-voice-tts.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",