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 +43 -0
- package/dist/client.js +180 -42
- package/dist/host/routes/asr.js +14 -4
- package/dist/host/routes/tts.js +9 -1
- package/dist/shared/immutable.js +34 -11
- package/dist/shared/noise.js +15 -4
- 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 +2 -2
- package/docs//350/267/250/345/271/263/345/217/260/344/270/216/345/210/206/345/267/245.md +7 -0
- package/package.json +2 -2
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
|
|
3
|
+
function freezeInto(value, seen) {
|
|
4
4
|
if (value === null || typeof value !== "object") return value;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
Object.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
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
|
-
|
|
1256
|
-
} catch (
|
|
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
|
-
|
|
1278
|
-
|
|
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,
|
|
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(
|
|
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" ||
|
|
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")
|
|
4393
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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) {
|
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 秒
|
|
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
|
-
|
|
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
|
-
|
|
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') {
|
package/dist/host/routes/tts.js
CHANGED
|
@@ -54,7 +54,15 @@ export function createTtsHandler(opts) {
|
|
|
54
54
|
return;
|
|
55
55
|
}
|
|
56
56
|
const body = parsed.body;
|
|
57
|
-
const
|
|
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 : '';
|
package/dist/shared/immutable.js
CHANGED
|
@@ -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
|
-
|
|
16
|
+
/** 递归冻结的实体(WeakSet 同时解决"循环引用"和"同一对象重复出现") */
|
|
17
|
+
function freezeInto(value, seen) {
|
|
8
18
|
if (value === null || typeof value !== 'object')
|
|
9
19
|
return value;
|
|
10
|
-
|
|
11
|
-
if (Object.isFrozen(obj))
|
|
20
|
+
if (seen.has(value))
|
|
12
21
|
return value;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 (
|
|
20
|
-
for (const
|
|
21
|
-
|
|
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
|
+
}
|
package/dist/shared/noise.js
CHANGED
|
@@ -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
|
-
* ③
|
|
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 (
|
|
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.
|
|
16
|
-
| `src/client/version.ts` VERSION | 1.1.
|
|
17
|
-
| 最近 Gitee tag | `v1.1.
|
|
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.
|
|
20
|
+
| npm latest | **1.1.64**(发布后轮询确认) |
|
|
21
21
|
| README 版本 | 无硬编码版本号 |
|
|
22
|
-
| `npm test` | **
|
|
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 个模块文件,
|
|
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.
|
|
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",
|