dsh-voice-mode 0.6.0 → 0.7.0
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/lib/client.js +403 -32
- package/lib/index.js +3 -2
- package/package.json +24 -10
- package/scripts/check-anchors.mjs +85 -0
package/lib/client.js
CHANGED
|
@@ -72,9 +72,288 @@ function matchWakeWord(partial, wakeWord) {
|
|
|
72
72
|
return false;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// src/fixture-recorder.ts
|
|
76
|
+
var FLAG = "dsh-voice-mode.record";
|
|
77
|
+
var MAX_SECONDS = 180;
|
|
78
|
+
var SAMPLE_RATE = 16e3;
|
|
79
|
+
function recordMode() {
|
|
80
|
+
try {
|
|
81
|
+
const v = localStorage.getItem(FLAG);
|
|
82
|
+
if (v === "full") return "full";
|
|
83
|
+
if (v === "meta" || v === "1") return "meta";
|
|
84
|
+
} catch {
|
|
85
|
+
}
|
|
86
|
+
return "off";
|
|
87
|
+
}
|
|
88
|
+
var Track = class {
|
|
89
|
+
chunks = [];
|
|
90
|
+
total = 0;
|
|
91
|
+
push(f) {
|
|
92
|
+
const out = new Int16Array(f.length);
|
|
93
|
+
for (let i = 0; i < f.length; i++) {
|
|
94
|
+
const v = Math.max(-1, Math.min(1, f[i]));
|
|
95
|
+
out[i] = v < 0 ? v * 32768 : v * 32767;
|
|
96
|
+
}
|
|
97
|
+
this.chunks.push(out);
|
|
98
|
+
this.total += out.length;
|
|
99
|
+
}
|
|
100
|
+
get length() {
|
|
101
|
+
return this.total;
|
|
102
|
+
}
|
|
103
|
+
toBase64() {
|
|
104
|
+
const all = new Int16Array(this.total);
|
|
105
|
+
let off = 0;
|
|
106
|
+
for (const c of this.chunks) {
|
|
107
|
+
all.set(c, off);
|
|
108
|
+
off += c.length;
|
|
109
|
+
}
|
|
110
|
+
const bytes = new Uint8Array(all.buffer);
|
|
111
|
+
let bin = "";
|
|
112
|
+
const STEP = 32768;
|
|
113
|
+
for (let i = 0; i < bytes.length; i += STEP) {
|
|
114
|
+
bin += String.fromCharCode(...bytes.subarray(i, i + STEP));
|
|
115
|
+
}
|
|
116
|
+
return btoa(bin);
|
|
117
|
+
}
|
|
118
|
+
clear() {
|
|
119
|
+
this.chunks = [];
|
|
120
|
+
this.total = 0;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
var FixtureRecorder = class {
|
|
124
|
+
mode = "off";
|
|
125
|
+
active = false;
|
|
126
|
+
startedAt = 0;
|
|
127
|
+
frames = [];
|
|
128
|
+
marks = [];
|
|
129
|
+
detects = [];
|
|
130
|
+
micTrack = new Track();
|
|
131
|
+
refTrack = new Track();
|
|
132
|
+
resTrack = new Track();
|
|
133
|
+
/** 残差与 mic 是否出现过差异(原生 AEC 失效时自研 NLMS 生效)——决定是否落残差轨。 */
|
|
134
|
+
resDiffers = false;
|
|
135
|
+
env = {};
|
|
136
|
+
badge = null;
|
|
137
|
+
userSpeaking = false;
|
|
138
|
+
keyHandler = null;
|
|
139
|
+
get isActive() {
|
|
140
|
+
return this.active;
|
|
141
|
+
}
|
|
142
|
+
/** 进入语音模式时调用。档位为 off 时什么都不做。 */
|
|
143
|
+
begin(env) {
|
|
144
|
+
const mode = recordMode();
|
|
145
|
+
if (mode === "off") {
|
|
146
|
+
this.mode = "off";
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (this.active) this.save("restart");
|
|
150
|
+
this.mode = mode;
|
|
151
|
+
this.active = true;
|
|
152
|
+
this.startedAt = Date.now();
|
|
153
|
+
this.frames = [];
|
|
154
|
+
this.marks = [];
|
|
155
|
+
this.detects = [];
|
|
156
|
+
this.micTrack.clear();
|
|
157
|
+
this.refTrack.clear();
|
|
158
|
+
this.resTrack.clear();
|
|
159
|
+
this.resDiffers = false;
|
|
160
|
+
this.userSpeaking = false;
|
|
161
|
+
this.env = env;
|
|
162
|
+
this.mark("begin", mode);
|
|
163
|
+
this.mountBadge();
|
|
164
|
+
this.bindKeys();
|
|
165
|
+
try {
|
|
166
|
+
;
|
|
167
|
+
window.__dshvmRec = {
|
|
168
|
+
\u8BF4\u8BDD\u5F00\u59CB: () => this.setUserSpeaking(true),
|
|
169
|
+
\u8BF4\u8BDD\u7ED3\u675F: () => this.setUserSpeaking(false),
|
|
170
|
+
\u4FDD\u5B58: () => this.save("console"),
|
|
171
|
+
\u6807\u6CE8: (kind, note) => this.mark(kind, note),
|
|
172
|
+
\u72B6\u6001: () => ({ mode: this.mode, \u5E27\u6570: this.frames.length, \u6807\u6CE8\u6570: this.marks.length, \u8BF4\u8BDD\u4E2D: this.userSpeaking })
|
|
173
|
+
};
|
|
174
|
+
} catch {
|
|
175
|
+
}
|
|
176
|
+
console.log(
|
|
177
|
+
`[dsh-voice][rec] \u5F00\u59CB\u5F55\u5236\uFF08${mode}\uFF09\xB7 F8=\u6807\u6CE8\u8BF4\u8BDD \xB7 F9=\u4FDD\u5B58\u4E0B\u8F7D
|
|
178
|
+
[dsh-voice][rec] \u952E\u76D8\u4E0D\u7075\u65F6\u7528\u63A7\u5236\u53F0\uFF1A__dshvmRec.\u8BF4\u8BDD\u5F00\u59CB() / .\u8BF4\u8BDD\u7ED3\u675F() / .\u4FDD\u5B58() / .\u72B6\u6001()`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* 逐帧写入。在 asr.ts handleAudio 里 AEC 与门控统计算完之后调用。
|
|
183
|
+
* micPre = AEC 前(重采样后);ref = 参考窗;res = AEC 后残差(判定链实际输入)。
|
|
184
|
+
*/
|
|
185
|
+
frame(micPre, ref, res, stats) {
|
|
186
|
+
if (!this.active) return;
|
|
187
|
+
const t3 = Date.now() - this.startedAt;
|
|
188
|
+
if (t3 > MAX_SECONDS * 1e3) {
|
|
189
|
+
this.mark("auto-stop", `\u5230\u8FBE ${MAX_SECONDS}s \u4E0A\u9650`);
|
|
190
|
+
this.save("maxlen");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
this.frames.push({
|
|
194
|
+
t: t3,
|
|
195
|
+
rms: round6(stats.rms),
|
|
196
|
+
mic: round6(rmsOf(micPre)),
|
|
197
|
+
ref: round6(ref ? rmsOf(ref) : 0),
|
|
198
|
+
fl: round6(stats.floorRms),
|
|
199
|
+
pk: round6(stats.peakRms),
|
|
200
|
+
dt: stats.doubleTalk ? 1 : 0,
|
|
201
|
+
pt: stats.playingTail ? 1 : 0
|
|
202
|
+
});
|
|
203
|
+
if (this.mode === "full") {
|
|
204
|
+
this.micTrack.push(micPre);
|
|
205
|
+
this.refTrack.push(ref ?? new Float32Array(micPre.length));
|
|
206
|
+
if (res !== micPre) {
|
|
207
|
+
if (!this.resDiffers) this.resDiffers = true;
|
|
208
|
+
this.resTrack.push(res);
|
|
209
|
+
} else if (this.resDiffers) {
|
|
210
|
+
this.resTrack.push(res);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
this.refreshBadge();
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* 检测通道一次往返(A 档埋点):分离「请求在途慢」与「客户端没排上」。
|
|
217
|
+
* 停顿归因的唯一依据——回报间隔 = 往返耗时 + 客户端等待,此处记的是前者。
|
|
218
|
+
*/
|
|
219
|
+
noteDetect(sentAt, rttMs, ok, samples) {
|
|
220
|
+
if (!this.active) return;
|
|
221
|
+
this.detects.push({ t: sentAt - this.startedAt, rtt: rttMs, ok: ok ? 1 : 0, n: samples });
|
|
222
|
+
}
|
|
223
|
+
/** host 下行 isSpeech:盖在最近一帧上。 */
|
|
224
|
+
noteIsSpeech(speech) {
|
|
225
|
+
if (!this.active || speech === void 0) return;
|
|
226
|
+
const last = this.frames[this.frames.length - 1];
|
|
227
|
+
if (last) last.spk = speech ? 1 : 0;
|
|
228
|
+
}
|
|
229
|
+
/** 播放态(裸口径)变化 / 打断触发 / 句子边界等事件。 */
|
|
230
|
+
mark(kind, note) {
|
|
231
|
+
if (!this.active && kind !== "begin") return;
|
|
232
|
+
this.marks.push({ t: Date.now() - this.startedAt, kind, note });
|
|
233
|
+
}
|
|
234
|
+
/** 保存并触发下载。reason 只用于文件名与日志。 */
|
|
235
|
+
save(reason = "manual") {
|
|
236
|
+
if (!this.active) return;
|
|
237
|
+
this.active = false;
|
|
238
|
+
this.unbindKeys();
|
|
239
|
+
this.unmountBadge();
|
|
240
|
+
const durationMs = Date.now() - this.startedAt;
|
|
241
|
+
const payload = {
|
|
242
|
+
schema: "dsh-voice-mode/fixture@1",
|
|
243
|
+
recordedAt: new Date(this.startedAt).toISOString(),
|
|
244
|
+
reason,
|
|
245
|
+
mode: this.mode,
|
|
246
|
+
sampleRate: SAMPLE_RATE,
|
|
247
|
+
durationMs,
|
|
248
|
+
env: this.env,
|
|
249
|
+
frames: this.frames,
|
|
250
|
+
marks: this.marks,
|
|
251
|
+
detects: this.detects
|
|
252
|
+
};
|
|
253
|
+
if (this.mode === "full" && this.micTrack.length > 0) {
|
|
254
|
+
payload.audio = {
|
|
255
|
+
encoding: "int16le-base64",
|
|
256
|
+
mic: this.micTrack.toBase64(),
|
|
257
|
+
ref: this.refTrack.toBase64(),
|
|
258
|
+
...this.resDiffers ? { res: this.resTrack.toBase64() } : {}
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const name = `dshvm-fixture-${new Date(this.startedAt).toISOString().replace(/[:.]/g, "-")}-${reason}.json`;
|
|
262
|
+
try {
|
|
263
|
+
const blob = new Blob([JSON.stringify(payload)], { type: "application/json" });
|
|
264
|
+
const url = URL.createObjectURL(blob);
|
|
265
|
+
const a = document.createElement("a");
|
|
266
|
+
a.href = url;
|
|
267
|
+
a.download = name;
|
|
268
|
+
document.body.appendChild(a);
|
|
269
|
+
a.click();
|
|
270
|
+
a.remove();
|
|
271
|
+
setTimeout(() => URL.revokeObjectURL(url), 1e4);
|
|
272
|
+
console.log(
|
|
273
|
+
`[dsh-voice][rec] \u5DF2\u4FDD\u5B58 ${name}\uFF1A${this.frames.length} \u5E27 / ${(durationMs / 1e3).toFixed(1)}s / \u6807\u6CE8 ${this.marks.length} \u6761 / \u68C0\u6D4B\u5F80\u8FD4 ${this.detects.length} \u6B21`
|
|
274
|
+
);
|
|
275
|
+
} catch (e) {
|
|
276
|
+
console.warn("[dsh-voice][rec] \u4FDD\u5B58\u5931\u8D25\uFF1A" + String(e));
|
|
277
|
+
}
|
|
278
|
+
this.micTrack.clear();
|
|
279
|
+
this.refTrack.clear();
|
|
280
|
+
this.resTrack.clear();
|
|
281
|
+
}
|
|
282
|
+
/** 说话区间标注(键盘与控制台入口共用;重复置同一状态不产生重复标注)。 */
|
|
283
|
+
setUserSpeaking(on) {
|
|
284
|
+
if (!this.active || this.userSpeaking === on) return;
|
|
285
|
+
this.userSpeaking = on;
|
|
286
|
+
this.mark(on ? "user-speech-start" : "user-speech-end");
|
|
287
|
+
console.log(`[dsh-voice][rec] \u6807\u6CE8\uFF1A${on ? "\u5F00\u59CB\u8BF4\u8BDD" : "\u8BF4\u5B8C\u4E86"}`);
|
|
288
|
+
this.refreshBadge();
|
|
289
|
+
}
|
|
290
|
+
// ── 键盘标注 ──
|
|
291
|
+
bindKeys() {
|
|
292
|
+
this.keyHandler = (e) => {
|
|
293
|
+
if (e.key === "F8") {
|
|
294
|
+
e.preventDefault();
|
|
295
|
+
this.setUserSpeaking(!this.userSpeaking);
|
|
296
|
+
} else if (e.key === "F9") {
|
|
297
|
+
e.preventDefault();
|
|
298
|
+
this.save("hotkey");
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
window.addEventListener("keydown", this.keyHandler, true);
|
|
302
|
+
}
|
|
303
|
+
unbindKeys() {
|
|
304
|
+
if (this.keyHandler) window.removeEventListener("keydown", this.keyHandler, true);
|
|
305
|
+
this.keyHandler = null;
|
|
306
|
+
}
|
|
307
|
+
// ── 徽标(自包含 DOM,不接 React)──
|
|
308
|
+
mountBadge() {
|
|
309
|
+
try {
|
|
310
|
+
const el = document.createElement("div");
|
|
311
|
+
el.style.cssText = [
|
|
312
|
+
"position:fixed",
|
|
313
|
+
"top:8px",
|
|
314
|
+
"right:8px",
|
|
315
|
+
"z-index:2147483647",
|
|
316
|
+
"padding:6px 10px",
|
|
317
|
+
"border-radius:6px",
|
|
318
|
+
"background:rgba(180,20,20,.92)",
|
|
319
|
+
"color:#fff",
|
|
320
|
+
"font:12px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace",
|
|
321
|
+
"pointer-events:none",
|
|
322
|
+
"white-space:pre"
|
|
323
|
+
].join(";");
|
|
324
|
+
document.body.appendChild(el);
|
|
325
|
+
this.badge = el;
|
|
326
|
+
this.refreshBadge();
|
|
327
|
+
} catch {
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
refreshBadge() {
|
|
331
|
+
if (!this.badge) return;
|
|
332
|
+
const s = ((Date.now() - this.startedAt) / 1e3).toFixed(1);
|
|
333
|
+
const last = this.frames[this.frames.length - 1];
|
|
334
|
+
this.badge.textContent = `\u25CF REC ${this.mode} ${s}s ${this.frames.length}\u5E27` + (this.userSpeaking ? " [\u8BF4\u8BDD\u4E2D]" : "") + (last ? `
|
|
335
|
+
resid ${last.rms.toFixed(4)} floor ${last.fl.toFixed(4)} peak ${last.pk.toFixed(4)}` : "") + "\nF8 \u6807\u6CE8\u8BF4\u8BDD \xB7 F9 \u4FDD\u5B58";
|
|
336
|
+
}
|
|
337
|
+
unmountBadge() {
|
|
338
|
+
try {
|
|
339
|
+
this.badge?.remove();
|
|
340
|
+
} catch {
|
|
341
|
+
}
|
|
342
|
+
this.badge = null;
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
function rmsOf(x) {
|
|
346
|
+
if (x.length === 0) return 0;
|
|
347
|
+
let s = 0;
|
|
348
|
+
for (let i = 0; i < x.length; i++) s += x[i] * x[i];
|
|
349
|
+
return Math.sqrt(s / x.length);
|
|
350
|
+
}
|
|
351
|
+
var round6 = (v) => Math.round(v * 1e6) / 1e6;
|
|
352
|
+
var fixtureRecorder = new FixtureRecorder();
|
|
353
|
+
|
|
75
354
|
// src/asr.ts
|
|
76
355
|
var workletBlobUrl = null;
|
|
77
|
-
var
|
|
356
|
+
var SAMPLE_RATE2 = 16e3;
|
|
78
357
|
var SPEECH_RMS = 0.015;
|
|
79
358
|
var LEVEL_CEILING = 0.25;
|
|
80
359
|
var MAX_SEGMENT_MS = 3e4;
|
|
@@ -120,7 +399,7 @@ function createAsrEngine(config, sessionId) {
|
|
|
120
399
|
let stopRequested = false;
|
|
121
400
|
let startSeq = 0;
|
|
122
401
|
let inFlush = false;
|
|
123
|
-
let ctxRate =
|
|
402
|
+
let ctxRate = SAMPLE_RATE2;
|
|
124
403
|
let speechActive = false;
|
|
125
404
|
let segment = [];
|
|
126
405
|
let segmentMs = 0;
|
|
@@ -179,7 +458,7 @@ function createAsrEngine(config, sessionId) {
|
|
|
179
458
|
const requestPartial = async () => {
|
|
180
459
|
if (partialInFlight || segment.length === 0) return;
|
|
181
460
|
const total = segment.reduce((n, c) => n + c.length, 0);
|
|
182
|
-
const seconds = total /
|
|
461
|
+
const seconds = total / SAMPLE_RATE2;
|
|
183
462
|
if (seconds < PARTIAL_MIN_S || seconds > PARTIAL_MAX_S) return;
|
|
184
463
|
const from = uploadedSamples;
|
|
185
464
|
if (total - from <= 0) return;
|
|
@@ -257,7 +536,7 @@ function createAsrEngine(config, sessionId) {
|
|
|
257
536
|
const requestDetect = async () => {
|
|
258
537
|
if (detectInFlight) return;
|
|
259
538
|
let total = detectChunks.reduce((n, c) => n + c.length, 0);
|
|
260
|
-
const MAX_DETECT_PENDING =
|
|
539
|
+
const MAX_DETECT_PENDING = 1 * SAMPLE_RATE2;
|
|
261
540
|
if (total - detectSent > MAX_DETECT_PENDING) {
|
|
262
541
|
detectSent = Math.max(0, total - MAX_DETECT_PENDING);
|
|
263
542
|
}
|
|
@@ -271,6 +550,8 @@ function createAsrEngine(config, sessionId) {
|
|
|
271
550
|
const epoch = segmentEpoch;
|
|
272
551
|
const gen = detectGeneration;
|
|
273
552
|
detectInFlight = true;
|
|
553
|
+
const sentAt = Date.now();
|
|
554
|
+
let rttMs = -1;
|
|
274
555
|
try {
|
|
275
556
|
const res = await fetch(asrUrl(false, detectSent, epoch) + "&vadOnly=1", {
|
|
276
557
|
method: "POST",
|
|
@@ -279,6 +560,7 @@ function createAsrEngine(config, sessionId) {
|
|
|
279
560
|
signal: AbortSignal.timeout(5e3)
|
|
280
561
|
// 防服务端挂起长期锁死 detectInFlight(Important#2)
|
|
281
562
|
});
|
|
563
|
+
rttMs = Date.now() - sentAt;
|
|
282
564
|
if (epoch !== segmentEpoch || gen !== detectGeneration) return;
|
|
283
565
|
if (!res.ok) return;
|
|
284
566
|
const out = await res.json();
|
|
@@ -292,6 +574,9 @@ function createAsrEngine(config, sessionId) {
|
|
|
292
574
|
} catch {
|
|
293
575
|
} finally {
|
|
294
576
|
detectInFlight = false;
|
|
577
|
+
if (fixtureRecorder.isActive) {
|
|
578
|
+
fixtureRecorder.noteDetect(sentAt, rttMs < 0 ? Date.now() - sentAt : rttMs, rttMs >= 0, samples.length);
|
|
579
|
+
}
|
|
295
580
|
}
|
|
296
581
|
};
|
|
297
582
|
const resetHostStream = async () => {
|
|
@@ -409,15 +694,18 @@ function createAsrEngine(config, sessionId) {
|
|
|
409
694
|
let echoPeak = 0;
|
|
410
695
|
const handleAudio = (raw) => {
|
|
411
696
|
if (!active || inFlush) return;
|
|
412
|
-
let data = ctxRate !==
|
|
697
|
+
let data = ctxRate !== SAMPLE_RATE2 ? resampleLinear(raw, ctxRate, SAMPLE_RATE2) : raw;
|
|
698
|
+
const recMicPre = data;
|
|
699
|
+
let recRef = null;
|
|
413
700
|
if (echo) {
|
|
414
701
|
const ref = echo.windowAt(performance.now(), data.length);
|
|
702
|
+
recRef = ref;
|
|
415
703
|
data = echo.process(data, ref);
|
|
416
704
|
}
|
|
417
705
|
let sum = 0;
|
|
418
706
|
for (let i = 0; i < data.length; i++) sum += data[i] * data[i];
|
|
419
707
|
const rms = Math.sqrt(sum / data.length);
|
|
420
|
-
const durationMs = data.length /
|
|
708
|
+
const durationMs = data.length / SAMPLE_RATE2 * 1e3;
|
|
421
709
|
for (const fn of levelListeners) {
|
|
422
710
|
try {
|
|
423
711
|
fn(Math.min(1, rms / LEVEL_CEILING));
|
|
@@ -442,6 +730,15 @@ function createAsrEngine(config, sessionId) {
|
|
|
442
730
|
if (echo) {
|
|
443
731
|
echo.setFrozen(doubleTalk);
|
|
444
732
|
}
|
|
733
|
+
if (fixtureRecorder.isActive) {
|
|
734
|
+
fixtureRecorder.frame(recMicPre, recRef, data, {
|
|
735
|
+
rms,
|
|
736
|
+
floorRms: echoFloorRms,
|
|
737
|
+
peakRms: echoPeak,
|
|
738
|
+
doubleTalk,
|
|
739
|
+
playingTail: playingNow
|
|
740
|
+
});
|
|
741
|
+
}
|
|
445
742
|
if (playingNow && !holdActive && config.mode !== "hold") detectChunks.push(data);
|
|
446
743
|
if (holdActive) {
|
|
447
744
|
if (!speechActive) {
|
|
@@ -468,23 +765,23 @@ function createAsrEngine(config, sessionId) {
|
|
|
468
765
|
} else if (rms > SPEECH_RMS) {
|
|
469
766
|
if (config.isPlaying?.()) {
|
|
470
767
|
if (speechActive) finalizeSegment(true);
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
768
|
+
} else {
|
|
769
|
+
if (!speechActive) {
|
|
770
|
+
speechActive = true;
|
|
771
|
+
detectChunks = [];
|
|
772
|
+
detectSent = 0;
|
|
773
|
+
detectGeneration++;
|
|
774
|
+
utteranceEndAt = null;
|
|
775
|
+
setState("speech");
|
|
776
|
+
for (const p of prePad) segment.push(p);
|
|
777
|
+
prePad = [];
|
|
778
|
+
}
|
|
779
|
+
speechMs += durationMs;
|
|
780
|
+
segmentMs += durationMs;
|
|
781
|
+
silenceMs = 0;
|
|
782
|
+
segment.push(data);
|
|
783
|
+
if (segmentMs > MAX_SEGMENT_MS) finalizeSegment();
|
|
482
784
|
}
|
|
483
|
-
speechMs += durationMs;
|
|
484
|
-
segmentMs += durationMs;
|
|
485
|
-
silenceMs = 0;
|
|
486
|
-
segment.push(data);
|
|
487
|
-
if (segmentMs > MAX_SEGMENT_MS) finalizeSegment();
|
|
488
785
|
} else if (speechActive) {
|
|
489
786
|
if (utteranceEndAt === null) {
|
|
490
787
|
utteranceEndAt = Date.now();
|
|
@@ -515,7 +812,7 @@ function createAsrEngine(config, sessionId) {
|
|
|
515
812
|
let total = 0;
|
|
516
813
|
let cut = 0;
|
|
517
814
|
for (let i = prePad.length - 1; i >= 0; i--) {
|
|
518
|
-
total += prePad[i].length /
|
|
815
|
+
total += prePad[i].length / SAMPLE_RATE2 * 1e3;
|
|
519
816
|
if (total > PRE_PAD_MS) {
|
|
520
817
|
cut = i + 1;
|
|
521
818
|
break;
|
|
@@ -526,11 +823,15 @@ function createAsrEngine(config, sessionId) {
|
|
|
526
823
|
const nowMs = Date.now();
|
|
527
824
|
if (nowMs - lastPollAt >= PARTIAL_INTERVAL_MS) {
|
|
528
825
|
if (playingNow && !speechActive && !holdActive) {
|
|
529
|
-
|
|
530
|
-
|
|
826
|
+
if (!detectInFlight) {
|
|
827
|
+
lastPollAt = nowMs;
|
|
828
|
+
void requestDetect();
|
|
829
|
+
}
|
|
531
830
|
} else if (speechActive || holdActive || state === "wake") {
|
|
532
|
-
|
|
533
|
-
|
|
831
|
+
if (!partialInFlight) {
|
|
832
|
+
lastPollAt = nowMs;
|
|
833
|
+
void requestPartial();
|
|
834
|
+
}
|
|
534
835
|
}
|
|
535
836
|
}
|
|
536
837
|
};
|
|
@@ -559,7 +860,7 @@ function createAsrEngine(config, sessionId) {
|
|
|
559
860
|
}
|
|
560
861
|
config.onAecState?.(aecOn);
|
|
561
862
|
const AC = window.AudioContext ?? window.webkitAudioContext;
|
|
562
|
-
audioCtx = new AC({ sampleRate:
|
|
863
|
+
audioCtx = new AC({ sampleRate: SAMPLE_RATE2 });
|
|
563
864
|
try {
|
|
564
865
|
await audioCtx.resume?.();
|
|
565
866
|
} catch {
|
|
@@ -578,7 +879,7 @@ function createAsrEngine(config, sessionId) {
|
|
|
578
879
|
};
|
|
579
880
|
source.connect(workletNode);
|
|
580
881
|
workletNode.connect(audioCtx.destination);
|
|
581
|
-
ctxRate =
|
|
882
|
+
ctxRate = SAMPLE_RATE2;
|
|
582
883
|
active = true;
|
|
583
884
|
return;
|
|
584
885
|
} catch {
|
|
@@ -633,7 +934,7 @@ function createAsrEngine(config, sessionId) {
|
|
|
633
934
|
} catch {
|
|
634
935
|
}
|
|
635
936
|
audioCtx = null;
|
|
636
|
-
ctxRate =
|
|
937
|
+
ctxRate = SAMPLE_RATE2;
|
|
637
938
|
inFlush = false;
|
|
638
939
|
};
|
|
639
940
|
return {
|
|
@@ -681,7 +982,7 @@ function createAsrEngine(config, sessionId) {
|
|
|
681
982
|
setState("idle");
|
|
682
983
|
},
|
|
683
984
|
forceSend() {
|
|
684
|
-
const speechS = segment.reduce((n, c) => n + c.length, 0) /
|
|
985
|
+
const speechS = segment.reduce((n, c) => n + c.length, 0) / SAMPLE_RATE2;
|
|
685
986
|
if (speechActive && speechS >= 0.25) {
|
|
686
987
|
forcePending = true;
|
|
687
988
|
lastPollAt = 0;
|
|
@@ -2184,7 +2485,7 @@ var TELEMETRY_VIEW = [
|
|
|
2184
2485
|
{ stage: "first-tts-chunk", key: "telFirstChunk" },
|
|
2185
2486
|
{ stage: "first-audio-played", key: "telFirstPlayed" }
|
|
2186
2487
|
];
|
|
2187
|
-
var BUILD_TAG = "
|
|
2488
|
+
var BUILD_TAG = "054e486";
|
|
2188
2489
|
var TELEMETRY_FLAG = "dsh-voice-mode.telemetry";
|
|
2189
2490
|
var telemetryEnabled = typeof localStorage !== "undefined" && localStorage.getItem(TELEMETRY_FLAG) === "1";
|
|
2190
2491
|
console.log("[dsh-voice] build=" + BUILD_TAG);
|
|
@@ -2377,6 +2678,7 @@ function createAudioEngine(setUi, onPlayed, onPlaybackRef, onAllPlayed) {
|
|
|
2377
2678
|
}
|
|
2378
2679
|
};
|
|
2379
2680
|
setUi({ playing: true, playingCaption: frame.text, ttsNotice: null });
|
|
2681
|
+
fixtureRecorder.mark("tts-sentence", frame.text);
|
|
2380
2682
|
void fallbackAudio.play().catch(() => playFallback());
|
|
2381
2683
|
};
|
|
2382
2684
|
const drainPending = () => {
|
|
@@ -2419,6 +2721,7 @@ function createAudioEngine(setUi, onPlayed, onPlaybackRef, onAllPlayed) {
|
|
|
2419
2721
|
}
|
|
2420
2722
|
captionQueue.push(frame.text);
|
|
2421
2723
|
setUi({ playing: true, playingCaption: captionQueue[0], ttsNotice: null });
|
|
2724
|
+
fixtureRecorder.mark("tts-sentence", frame.text);
|
|
2422
2725
|
}
|
|
2423
2726
|
} catch {
|
|
2424
2727
|
for (const src of activeSrcs) {
|
|
@@ -2911,6 +3214,16 @@ function useVoiceCss() {
|
|
|
2911
3214
|
@keyframes dshvm-eq { 0%, 100% { transform: scaleY(0.35) } 50% { transform: scaleY(1) } }
|
|
2912
3215
|
@keyframes dshvm-spin { to { transform: rotate(360deg) } }
|
|
2913
3216
|
.dshvm-bar { width: 3px; border-radius: 99px; transition: height 0.08s linear, opacity 0.08s linear }
|
|
3217
|
+
/* \u9EA6\u514B\u98CE\u6309\u94AE\u6240\u5728\u7684\u5BBF\u4E3B\u5BB9\u5668\u4E5F\u7981\u9009\uFF1A\u624B\u6307\u504F\u5927\u65F6\u957F\u6309\u53EF\u80FD\u547D\u4E2D\u6309\u94AE\u5916\u4FA7\u7684\u5BB9\u5668\u7559\u767D\uFF0C
|
|
3218
|
+
\u6D4F\u89C8\u5668\u5C31\u8FD1\u9009\u4E2D\u300C\u8BED\u97F3\u300D\u6807\u7B7E\u6587\u5B57\u3002 */
|
|
3219
|
+
:has(> [data-dshvm="mic"]) { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none }
|
|
3220
|
+
/* \u6309\u4F4F\u8BF4\u8BDD\u671F\u95F4\u6574\u9875\u7981\u9009\uFF08!important \u538B\u8FC7\u5BBF\u4E3B\u6837\u5F0F\uFF09\uFF1A\u5B89\u5353/\u684C\u9762\u5728\u957F\u6309\u6216\u6309\u4F4F\u5FAE\u62D6\u65F6
|
|
3221
|
+
\u4F1A\u4ECE\u6309\u94AE\u9644\u8FD1\u5F00\u59CB\u9009\u533A\uFF0C\u51FA\u73B0\u84DD\u8272\u9AD8\u4EAE\u548C\u9009\u62E9\u624B\u67C4\uFF0C\u5BFC\u81F4\u300C\u6309\u4F4F\u8BF4\u8BDD\u300D\u4E0D\u53EF\u7528\u3002 */
|
|
3222
|
+
html.dshvm-holding, html.dshvm-holding * {
|
|
3223
|
+
-webkit-user-select: none !important;
|
|
3224
|
+
user-select: none !important;
|
|
3225
|
+
-webkit-touch-callout: none !important;
|
|
3226
|
+
}
|
|
2914
3227
|
`;
|
|
2915
3228
|
document.head.appendChild(el);
|
|
2916
3229
|
}, []);
|
|
@@ -3022,6 +3335,7 @@ function MicButton({
|
|
|
3022
3335
|
clearIdle();
|
|
3023
3336
|
cancelPendingSubmit();
|
|
3024
3337
|
isSpeechTrueCount = 0;
|
|
3338
|
+
fixtureRecorder.save("exit");
|
|
3025
3339
|
breakRef.current = null;
|
|
3026
3340
|
manualHoldRef.current = false;
|
|
3027
3341
|
clearBreakTimer();
|
|
@@ -3103,6 +3417,7 @@ function MicButton({
|
|
|
3103
3417
|
// 判真实人声前沿;仅 AI 朗读中(bus.ui.playing)触发 hardBreak,
|
|
3104
3418
|
// 防 TTS 回声被 VAD 误判为语音而自打断。
|
|
3105
3419
|
onIsSpeech: (speech) => {
|
|
3420
|
+
fixtureRecorder.noteIsSpeech(speech);
|
|
3106
3421
|
if (bargeInMode === "manual") return;
|
|
3107
3422
|
if (!bus.ui.playing) {
|
|
3108
3423
|
isSpeechTrueCount = 0;
|
|
@@ -3149,6 +3464,7 @@ function MicButton({
|
|
|
3149
3464
|
});
|
|
3150
3465
|
interruptFirstAt = 0;
|
|
3151
3466
|
isSpeechTrueCount = 0;
|
|
3467
|
+
fixtureRecorder.mark("interrupt", `confirmMs=${confirmMs}`);
|
|
3152
3468
|
resetIdle();
|
|
3153
3469
|
bus.resetTelemetry();
|
|
3154
3470
|
bus.setUi({ interruptConfirmMs: confirmMs });
|
|
@@ -3182,12 +3498,20 @@ function MicButton({
|
|
|
3182
3498
|
debugLog("aec-state", { nativeEchoCancellation: on2 });
|
|
3183
3499
|
bus.setEchoBypass(on2);
|
|
3184
3500
|
bus.setUi({ aecOff: !on2 });
|
|
3501
|
+
fixtureRecorder.mark("native-aec", on2 ? "on\uFF08\u81EA\u7814 NLMS \u65C1\u8DEF\uFF09" : "off\uFF08\u81EA\u7814 NLMS \u751F\u6548\uFF09");
|
|
3185
3502
|
}
|
|
3186
3503
|
},
|
|
3187
3504
|
sid
|
|
3188
3505
|
);
|
|
3189
3506
|
bus.setUi({ mode: cfg.mode });
|
|
3190
3507
|
engineRef.current = engine;
|
|
3508
|
+
fixtureRecorder.begin({
|
|
3509
|
+
build: BUILD_TAG,
|
|
3510
|
+
mode: cfg.mode,
|
|
3511
|
+
bargeInMode,
|
|
3512
|
+
echoGateDb: cfg.echoGateDb,
|
|
3513
|
+
interruptLevel
|
|
3514
|
+
});
|
|
3191
3515
|
engine.onTelemetry((e) => bus.stampTelemetry(e.stage, e.at));
|
|
3192
3516
|
bus.warmAudio();
|
|
3193
3517
|
try {
|
|
@@ -3502,7 +3826,53 @@ function MicButton({
|
|
|
3502
3826
|
breakTimerRef.current = null;
|
|
3503
3827
|
}
|
|
3504
3828
|
};
|
|
3829
|
+
const selectGuardRef = (0, import_react2.useRef)(null);
|
|
3830
|
+
const unlockSelection = () => {
|
|
3831
|
+
const off = selectGuardRef.current;
|
|
3832
|
+
if (!off) return;
|
|
3833
|
+
selectGuardRef.current = null;
|
|
3834
|
+
off();
|
|
3835
|
+
try {
|
|
3836
|
+
window.getSelection()?.removeAllRanges();
|
|
3837
|
+
} catch {
|
|
3838
|
+
}
|
|
3839
|
+
};
|
|
3840
|
+
const lockSelection = () => {
|
|
3841
|
+
if (selectGuardRef.current) return;
|
|
3842
|
+
const root = document.documentElement;
|
|
3843
|
+
root.classList.add("dshvm-holding");
|
|
3844
|
+
try {
|
|
3845
|
+
window.getSelection()?.removeAllRanges();
|
|
3846
|
+
} catch {
|
|
3847
|
+
}
|
|
3848
|
+
const stopSelect = (ev) => ev.preventDefault();
|
|
3849
|
+
const release = () => unlockSelection();
|
|
3850
|
+
document.addEventListener("selectstart", stopSelect, true);
|
|
3851
|
+
document.addEventListener("contextmenu", stopSelect, true);
|
|
3852
|
+
window.addEventListener("pointerup", release, true);
|
|
3853
|
+
window.addEventListener("pointercancel", release, true);
|
|
3854
|
+
selectGuardRef.current = () => {
|
|
3855
|
+
root.classList.remove("dshvm-holding");
|
|
3856
|
+
document.removeEventListener("selectstart", stopSelect, true);
|
|
3857
|
+
document.removeEventListener("contextmenu", stopSelect, true);
|
|
3858
|
+
window.removeEventListener("pointerup", release, true);
|
|
3859
|
+
window.removeEventListener("pointercancel", release, true);
|
|
3860
|
+
};
|
|
3861
|
+
};
|
|
3862
|
+
(0, import_react2.useEffect)(() => unlockSelection, []);
|
|
3863
|
+
const btnRef = (0, import_react2.useRef)(null);
|
|
3864
|
+
(0, import_react2.useEffect)(() => {
|
|
3865
|
+
const el = btnRef.current;
|
|
3866
|
+
if (!el) return;
|
|
3867
|
+
const onTouchStart = (ev) => {
|
|
3868
|
+
if (bootNow().mode !== "hold") return;
|
|
3869
|
+
if (ev.cancelable) ev.preventDefault();
|
|
3870
|
+
};
|
|
3871
|
+
el.addEventListener("touchstart", onTouchStart, { passive: false });
|
|
3872
|
+
return () => el.removeEventListener("touchstart", onTouchStart);
|
|
3873
|
+
}, []);
|
|
3505
3874
|
const onPointerDown = (e) => {
|
|
3875
|
+
lockSelection();
|
|
3506
3876
|
holdPtrRef.current = { t: Date.now(), y: e.clientY, id: e.pointerId };
|
|
3507
3877
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
3508
3878
|
if (bootNow().mode === "hold") {
|
|
@@ -3596,6 +3966,7 @@ function MicButton({
|
|
|
3596
3966
|
onPointerUp,
|
|
3597
3967
|
onPointerCancel,
|
|
3598
3968
|
onContextMenu: (e) => e.preventDefault(),
|
|
3969
|
+
ref: btnRef,
|
|
3599
3970
|
"data-dshvm": "mic",
|
|
3600
3971
|
"aria-label": on ? t("ariaActive") : t("ariaEnter"),
|
|
3601
3972
|
"aria-pressed": on,
|
package/lib/index.js
CHANGED
|
@@ -1968,8 +1968,9 @@ function apply(ctx, config) {
|
|
|
1968
1968
|
return next();
|
|
1969
1969
|
});
|
|
1970
1970
|
ctx.on("llm/stream", (options, next) => {
|
|
1971
|
-
const
|
|
1972
|
-
if (!config.enabled ||
|
|
1971
|
+
const rawSessionId = options.sessionId;
|
|
1972
|
+
if (!config.enabled || rawSessionId === void 0 || options.purpose !== void 0) return next();
|
|
1973
|
+
const sessionId = rawSessionId;
|
|
1973
1974
|
if (activeVoiceSession !== sessionId) return next();
|
|
1974
1975
|
const gen = (turnGen.get(sessionId) ?? 0) + 1;
|
|
1975
1976
|
turnGen.set(sessionId, gen);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-voice-mode",
|
|
3
3
|
"description": "Full-duplex voice mode for DeepSeek Harness: zipformer2 streaming ASR → editable draft, Edge TTS (default, fast) or local (VITS / Kokoro int8, privacy-first) read-aloud with live captions, true barge-in; hardened HTTP surface (loopback/origin/rate-limit) + model SHA256 pinning. · DSH 语音双工对话:流式识别入草稿、Edge 默认朗读或本地(VITS / Kokoro int8)朗读+实时字幕、开口即打断;HTTP 面安全加固。",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.7.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"repository": {
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"scripts/list-voices.mjs",
|
|
34
34
|
"scripts/bench-asr.mjs",
|
|
35
35
|
"scripts/verify.mjs",
|
|
36
|
+
"scripts/check-anchors.mjs",
|
|
36
37
|
"README.md",
|
|
37
38
|
"README.en.md",
|
|
38
39
|
"LICENSE"
|
|
@@ -45,8 +46,14 @@
|
|
|
45
46
|
"prefetch": "node scripts/prefetch.mjs",
|
|
46
47
|
"list-voices": "node scripts/list-voices.mjs",
|
|
47
48
|
"bench-asr": "node scripts/bench-asr.mjs",
|
|
49
|
+
"bench:echo-gate": "node scripts/bench-echo-gate.mjs",
|
|
50
|
+
"analyze:fixture": "node scripts/analyze-fixture.mjs",
|
|
48
51
|
"verify": "node scripts/verify.mjs",
|
|
49
|
-
"typecheck": "node node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node node_modules/typescript/bin/tsc -p tsconfig.client.json --noEmit"
|
|
52
|
+
"typecheck": "node node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node node_modules/typescript/bin/tsc -p tsconfig.client.json --noEmit",
|
|
53
|
+
"check:anchors": "node scripts/check-anchors.mjs",
|
|
54
|
+
"typecheck:dual": "bash scripts/typecheck-dual.sh",
|
|
55
|
+
"smoke:runtime": "bash scripts/smoke-runtime.sh",
|
|
56
|
+
"verify:dual": "bash scripts/verify-dual.sh"
|
|
50
57
|
},
|
|
51
58
|
"dsh": {
|
|
52
59
|
"bundle": {
|
|
@@ -55,8 +62,15 @@
|
|
|
55
62
|
"client": {
|
|
56
63
|
"platform": "web",
|
|
57
64
|
"inject": [
|
|
58
|
-
"@deepseek-ai/dsh-client-
|
|
59
|
-
"@deepseek-ai/dsh-client-
|
|
65
|
+
"@deepseek-ai/dsh-client-connection",
|
|
66
|
+
"@deepseek-ai/dsh-cordis-client-runner",
|
|
67
|
+
"@deepseek-ai/dsh-api-remotes",
|
|
68
|
+
"@deepseek-ai/dsh-client-locale",
|
|
69
|
+
"@deepseek-ai/dsh-client-ui-renderer",
|
|
70
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
71
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
72
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
73
|
+
"@deepseek-ai/dsh-client-ui-settings-plugins"
|
|
60
74
|
]
|
|
61
75
|
}
|
|
62
76
|
},
|
|
@@ -68,18 +82,18 @@
|
|
|
68
82
|
},
|
|
69
83
|
"peerDependencies": {
|
|
70
84
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
71
|
-
"@deepseek-ai/dsh-web": "^0.1.0-rc.6 || ^0.1.1-rc.0",
|
|
72
85
|
"react": "^18.2.0"
|
|
73
86
|
},
|
|
74
87
|
"devDependencies": {
|
|
75
88
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
76
|
-
"@deepseek-ai/dsh-host-webserver": "
|
|
77
|
-
"@deepseek-ai/dsh-llm": "
|
|
78
|
-
"@deepseek-ai/dsh-settings": "
|
|
79
|
-
"@deepseek-ai/dsh-system-prompt": "
|
|
89
|
+
"@deepseek-ai/dsh-host-webserver": "0.1.2-alpha.5",
|
|
90
|
+
"@deepseek-ai/dsh-llm": "0.1.2-alpha.5",
|
|
91
|
+
"@deepseek-ai/dsh-settings": "0.1.2-alpha.5",
|
|
92
|
+
"@deepseek-ai/dsh-system-prompt": "0.1.2-alpha.5",
|
|
80
93
|
"@types/node": "^26.0.0",
|
|
81
94
|
"@types/react": "^19.0.0",
|
|
82
95
|
"esbuild": "^0.25.0",
|
|
96
|
+
"playwright-core": "1.62.1",
|
|
83
97
|
"typescript": "^5.9.2"
|
|
84
98
|
},
|
|
85
99
|
"keywords": [
|
|
@@ -97,4 +111,4 @@
|
|
|
97
111
|
"access": "public"
|
|
98
112
|
},
|
|
99
113
|
"license": "MIT"
|
|
100
|
-
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 锚点健康检查(防回归 I-2)。
|
|
4
|
+
* 读取 package.json 的 dsh.client.inject 锚点,逐一校验这些锚点包在「目标 dsh 版本线」
|
|
5
|
+
* 上是否存在(默认 0.1.1-rc.2 与 0.1.2-alpha.4)。
|
|
6
|
+
*
|
|
7
|
+
* 目的:dsh 每次 minor 升级都可能删/改客户端锚点包(0.1.1→0.1.2 已删 dsh-client-runtime、
|
|
8
|
+
* 降级 dsh-client-ui-slots)。升级 dsh 前先跑本脚本,若某个锚点在新版本线缺失,脚本非零退出并点名,
|
|
9
|
+
* 避免「升级后客户端静默挂不上」。
|
|
10
|
+
*
|
|
11
|
+
* 用法:
|
|
12
|
+
* node scripts/check-anchors.mjs [目标版本...]
|
|
13
|
+
* (默认检查 0.1.1-rc.2 与 0.1.2-alpha.4;也可显式传版本,如 node scripts/check-anchors.mjs 0.1.3-rc.0)
|
|
14
|
+
*
|
|
15
|
+
* ── 实现说明(2026-09-02 改)──
|
|
16
|
+
* 初版用 `execFileSync('npm', ['view', ...])` 查询。该写法在 **Windows 上必然失败**:
|
|
17
|
+
* - `npm` 实为 `npm.cmd`,不经 shell 时 execFileSync 报 ENOENT;
|
|
18
|
+
* - 显式写 `npm.cmd` 又会被 Node 20+ 的安全限制拒绝(EINVAL,禁止 execFile 直接跑 .cmd/.bat)。
|
|
19
|
+
* 两种错误都被 catch 吞成「锚点不存在」,导致**全量假失败**(实测 win32 上 9 锚点 × 2 版本
|
|
20
|
+
* = 18 个假失败,而同样的包用 registry 直查全部存在)。
|
|
21
|
+
* 现改为直接走 registry HTTP:不 spawn 子进程,跨平台一致,且比逐个 `npm view` 快得多
|
|
22
|
+
* (每个包只取一次 packument,多版本复用)。
|
|
23
|
+
*/
|
|
24
|
+
import { readFileSync } from 'node:fs'
|
|
25
|
+
import { dirname, join } from 'node:path'
|
|
26
|
+
import { fileURLToPath } from 'node:url'
|
|
27
|
+
|
|
28
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
29
|
+
const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'))
|
|
30
|
+
const anchors = pkg?.dsh?.client?.inject ?? []
|
|
31
|
+
const targets = process.argv.slice(2).length ? process.argv.slice(2) : ['0.1.1-rc.2', '0.1.2-alpha.4']
|
|
32
|
+
|
|
33
|
+
if (anchors.length === 0) {
|
|
34
|
+
console.error('package.json 未声明 dsh.client.inject 锚点')
|
|
35
|
+
process.exit(1)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** registry:优先用 npm 注入的环境变量(npm run 时可得),否则官方源。 */
|
|
39
|
+
const REGISTRY = (process.env.npm_config_registry || 'https://registry.npmjs.org').replace(/\/+$/, '')
|
|
40
|
+
|
|
41
|
+
/** 取一次 packument,返回该包的版本集合;网络/服务端异常抛错(与「包不存在」区分)。 */
|
|
42
|
+
async function fetchVersions(name) {
|
|
43
|
+
// scoped 包的 `/` 在 registry 路径里要编码成 %2f
|
|
44
|
+
const url = `${REGISTRY}/${name.replace('/', '%2f')}`
|
|
45
|
+
const res = await fetch(url, { headers: { accept: 'application/vnd.npm.install-v1+json' } })
|
|
46
|
+
if (res.status === 404) return null // 包本身不存在
|
|
47
|
+
if (!res.ok) throw new Error(`${name}: HTTP ${res.status}`)
|
|
48
|
+
const body = await res.json()
|
|
49
|
+
return new Set(Object.keys(body.versions ?? {}))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log(`registry: ${REGISTRY}`)
|
|
53
|
+
|
|
54
|
+
/** 先把每个包的版本集合取回来(每包一次请求)。 */
|
|
55
|
+
const versionsByAnchor = new Map()
|
|
56
|
+
for (const anchor of anchors) {
|
|
57
|
+
try {
|
|
58
|
+
versionsByAnchor.set(anchor, await fetchVersions(anchor))
|
|
59
|
+
} catch (e) {
|
|
60
|
+
// 网络/registry 故障是**环境问题**,不该报成「锚点缺失」——用独立退出码 2 区分。
|
|
61
|
+
console.error(`\n⚠ 查询 ${anchor} 失败:${e.message}`)
|
|
62
|
+
console.error('这是环境/网络问题,不是锚点缺失。请检查网络或 registry 配置后重跑。')
|
|
63
|
+
process.exit(2)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let failures = 0
|
|
68
|
+
for (const target of targets) {
|
|
69
|
+
console.log(`\n=== 目标 dsh ${target} 下的锚点存在性 ===`)
|
|
70
|
+
for (const anchor of anchors) {
|
|
71
|
+
const versions = versionsByAnchor.get(anchor)
|
|
72
|
+
if (versions && versions.has(target)) {
|
|
73
|
+
console.log(` ✅ ${anchor} @ ${target}`)
|
|
74
|
+
} else {
|
|
75
|
+
console.error(` ❌ ${anchor} @ ${target} 不存在`)
|
|
76
|
+
failures++
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (failures > 0) {
|
|
82
|
+
console.error(`\n✗ 有 ${failures} 个锚点缺失:dsh.client.inject 需收敛为交集,或等待该包在新版本提供。`)
|
|
83
|
+
process.exit(1)
|
|
84
|
+
}
|
|
85
|
+
console.log(`\n✓ 全部 ${anchors.length} 个锚点在 ${targets.join(' 与 ')} 均存在。`)
|