@memef1f1y/dsh-plugin-tts 0.6.0 → 0.6.2

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.
@@ -0,0 +1,51 @@
1
+ name: Bug 反馈
2
+ description: 报告插件使用中遇到的问题
3
+ title: "[Bug] "
4
+ labels: ["bug"]
5
+ body:
6
+ - type: textarea
7
+ id: repro
8
+ attributes:
9
+ label: 复现步骤
10
+ description: 让维护者能复现问题的最简操作,1-2-3 列出来
11
+ placeholder: |
12
+ 1. 打开 ……
13
+ 2. 点击 ……
14
+ 3. 出现报错 ……
15
+ validations:
16
+ required: true
17
+ - type: textarea
18
+ id: actual
19
+ attributes:
20
+ label: 期望结果 vs 实际结果
21
+ description: 你期望发生什么,实际发生了什么
22
+ placeholder: |
23
+ 期望: assistant 回复后自动朗读
24
+ 实际: 没有任何声音,也没有报错
25
+ validations:
26
+ required: true
27
+ - type: input
28
+ id: env
29
+ attributes:
30
+ label: 环境
31
+ description: 插件版本 / opencode 版本 / 操作系统
32
+ placeholder: "插件 v0.3.0 / opencode 1.18.x / Windows 11"
33
+ validations:
34
+ required: true
35
+ - type: textarea
36
+ id: logs
37
+ attributes:
38
+ label: 日志或报错信息
39
+ description: 相关报错、控制台输出贴进来;确认没有请填"无"
40
+ render: shell
41
+ placeholder: "把报错堆栈粘在这里,没有请填 无"
42
+ validations:
43
+ required: true
44
+ - type: input
45
+ id: code
46
+ attributes:
47
+ label: 相关代码 / 文件(选填)
48
+ description: 出问题的文件路径、配置片段
49
+ placeholder: "如 hooks/useEdgeSpeech.ts,或相关配置"
50
+ validations:
51
+ required: false
@@ -0,0 +1,2 @@
1
+ blank_issues_enabled: false
2
+ contact_links: []
@@ -0,0 +1,28 @@
1
+ name: 功能建议
2
+ description: 给插件提新功能或改进想法
3
+ title: "[Feature] "
4
+ labels: ["enhancement"]
5
+ body:
6
+ - type: textarea
7
+ id: scene
8
+ attributes:
9
+ label: 使用场景
10
+ description: 你在什么情况下需要这个功能,现在是怎么凑合的
11
+ placeholder: "比如:开车时想听长回复,但当前只能手动点朗读……"
12
+ validations:
13
+ required: true
14
+ - type: textarea
15
+ id: proposal
16
+ attributes:
17
+ label: 建议方案
18
+ description: 你希望做成什么样,越具体越好
19
+ placeholder: "比如:加一个超过 200 字自动朗读的开关……"
20
+ validations:
21
+ required: true
22
+ - type: textarea
23
+ id: alt
24
+ attributes:
25
+ label: 替代方案(选填)
26
+ description: 有没有退而求其次也能接受的做法
27
+ validations:
28
+ required: false
@@ -0,0 +1,7 @@
1
+ ### 关联 issue
2
+
3
+ Closes #
4
+
5
+ ### 改了什么,为什么
6
+
7
+ ### 怎么验证的
@@ -0,0 +1,66 @@
1
+ # 内容初筛:正文过薄的 issue / PR 自动索要信息(只提醒不关闭)。
2
+ # 关闭逻辑在 stale.yml(7 天提醒 + 7 天关闭)。不 checkout 任何代码,
3
+ # pull_request_target 在此是安全的。
4
+ name: contrib-triage
5
+
6
+ on:
7
+ issues:
8
+ types: [opened, edited, reopened]
9
+ pull_request_target:
10
+ types: [opened, edited, reopened, ready_for_review]
11
+
12
+ permissions:
13
+ issues: write
14
+ pull-requests: write
15
+
16
+ jobs:
17
+ triage:
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/github-script@v8
21
+ with:
22
+ script: |
23
+ const isPR = !!context.payload.pull_request;
24
+ const target = isPR ? context.payload.pull_request : context.payload.issue;
25
+ // 注意:author_association 在 sender 上没有,要从 issue / PR 对象上取
26
+ const assoc = target.author_association ?? "";
27
+
28
+ // 豁免只管"打标"不管"摘标":自己人补了信息也要清标记,
29
+ // 否则标记永远烂在自己人手里,最后被 stale 误关
30
+ const privileged =
31
+ ["OWNER", "MEMBER", "COLLABORATOR"].includes(assoc) ||
32
+ context.payload.sender?.type === "Bot" ||
33
+ (isPR && target.draft);
34
+
35
+ // 去掉标题行/空行/注释后看有效字数
36
+ const effective = (target.body ?? "")
37
+ .split("\n")
38
+ .map((l) => l.trim())
39
+ .filter((l) => l && !l.startsWith("#") && !l.startsWith("<!--") && l !== "---")
40
+ .join("\n");
41
+ const thin = effective.length < (isPR ? 30 : 50);
42
+
43
+ const labels = target.labels.map((l) => l.name);
44
+ const hasFlag = labels.includes("needs:info");
45
+
46
+ if (!thin && hasFlag) {
47
+ await github.rest.issues.removeLabel({
48
+ ...context.repo,
49
+ issue_number: target.number,
50
+ name: "needs:info",
51
+ });
52
+ } else if (thin && !hasFlag && !privileged) {
53
+ await github.rest.issues.addLabels({
54
+ ...context.repo,
55
+ issue_number: target.number,
56
+ labels: ["needs:info"],
57
+ });
58
+ const msg = isPR
59
+ ? "感谢贡献!这个 PR 描述有点简单,维护者可能看不懂改了什么,麻烦按模板补一下:关联哪个 issue、改了什么为什么、怎么验证的。直接编辑 PR 正文即可,补好后标记会自动摘掉。"
60
+ : "感谢反馈!这个 issue 信息有点少,维护者可能无法定位问题,麻烦补充一下:1)复现步骤;2)期望结果 vs 实际结果;3)日志或报错信息。直接编辑 issue 正文补上就行,补好后标记会自动摘掉。";
61
+ await github.rest.issues.createComment({
62
+ ...context.repo,
63
+ issue_number: target.number,
64
+ body: msg,
65
+ });
66
+ }
@@ -0,0 +1,21 @@
1
+ # PR 必须关联 issue(描述里写 Closes #x / Fixes #x)。
2
+ # 贴 no-issue 标签可豁免(如纯文档、发版类 PR)。不 checkout 代码。
3
+ name: linked-issue
4
+
5
+ on:
6
+ pull_request_target:
7
+ types: [opened, edited, reopened, ready_for_review, labeled, unlabeled]
8
+
9
+ permissions:
10
+ issues: read
11
+ pull-requests: write
12
+
13
+ jobs:
14
+ check:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: nearform-actions/github-action-check-linked-issues@v1.8.3
18
+ with:
19
+ github-token: ${{ secrets.GITHUB_TOKEN }}
20
+ exclude-branches: "dependabot/**"
21
+ custom-body-comment: "这个 PR 还没有关联 issue,请在描述里加上 `Closes #xxx`(把 xxx 换成 issue 号)。纯文档/发版类 PR 可贴 `no-issue` 标签跳过此检查。"
@@ -0,0 +1,30 @@
1
+ # 只处理被 needs:info 标记的 issue / PR:7 天无更新提醒,再 7 天无更新关闭。
2
+ # 任何回复/编辑都会自动摘标并重启计时。
3
+ name: stale-guard
4
+
5
+ on:
6
+ schedule:
7
+ - cron: "0 1 * * *"
8
+ workflow_dispatch:
9
+
10
+ permissions:
11
+ issues: write
12
+ pull-requests: write
13
+
14
+ jobs:
15
+ stale:
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/stale@v10
19
+ with:
20
+ only-issue-labels: "needs:info"
21
+ only-pr-labels: "needs:info"
22
+ days-before-stale: 7
23
+ days-before-close: 7
24
+ stale-issue-label: "needs:info"
25
+ stale-pr-label: "needs:info"
26
+ exempt-draft-pr: true
27
+ stale-issue-message: "这个 issue 已 7 天没有补充信息,再过 7 天仍无更新将自动关闭。直接编辑正文或回复补充即可(随时可重开)。"
28
+ stale-pr-message: "这个 PR 已 7 天没有补充信息,再过 7 天仍无更新将自动关闭。直接编辑正文或回复补充即可(随时可重开)。"
29
+ close-issue-message: "因长期缺少定位信息自动关闭。如仍有问题,补充信息后可随时重开。"
30
+ close-pr-message: "因长期缺少信息自动关闭。补齐描述后可随时重开。"
@@ -0,0 +1,22 @@
1
+ # 首次贡献者欢迎(每人只打扰一次)。不 checkout 代码。
2
+ name: welcome
3
+
4
+ on:
5
+ issues:
6
+ types: [opened]
7
+ pull_request_target:
8
+ types: [opened]
9
+
10
+ permissions:
11
+ issues: write
12
+ pull-requests: write
13
+
14
+ jobs:
15
+ greet:
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/first-interaction@v3
19
+ with:
20
+ repo_token: ${{ secrets.GITHUB_TOKEN }}
21
+ issue_message: "欢迎第一次提 issue!如果还没填过信息模板,麻烦按表单把复现步骤和日志补一下,定位会快很多。"
22
+ pr_message: "欢迎第一次贡献代码!麻烦确认 PR 描述里写了关联 issue、改动原因和验证方式,review 会顺利很多。"
package/README.md CHANGED
@@ -274,7 +274,7 @@ Hot-reload after editing `lib/` (on Windows a `file:` install is a COPY, not a
274
274
  symlink, so the running dsh reads the profile copy):
275
275
 
276
276
  ```powershell
277
- Copy-Item lib/* $env:USERPROFILE\.dsh\profiles\web\node_modules\@dsh-external\dsh-plugin-tts\lib\ -Recurse -Force
277
+ Copy-Item lib/* $env:USERPROFILE\.dsh\profiles\web\node_modules\@memef1f1y\dsh-plugin-tts\lib\ -Recurse -Force
278
278
  # then refresh the browser (bundles are re-read from disk per request; never use pnpm install --force)
279
279
  ```
280
280
 
package/README.zh.md CHANGED
@@ -244,7 +244,7 @@ npm run test:all # 全量:smoke + live + patch + i18n + client-load
244
244
  运行中的 dsh 读的是 profile 副本):
245
245
 
246
246
  ```powershell
247
- Copy-Item lib/* $env:USERPROFILE\.dsh\profiles\web\node_modules\@dsh-external\dsh-plugin-tts\lib\ -Recurse -Force
247
+ Copy-Item lib/* $env:USERPROFILE\.dsh\profiles\web\node_modules\@memef1f1y\dsh-plugin-tts\lib\ -Recurse -Force
248
248
  # 然后刷新浏览器即可(bundle 每次请求重新读盘;勿用 pnpm install --force 覆盖)
249
249
  ```
250
250
 
package/cordis.patch.yml CHANGED
@@ -1,4 +1,4 @@
1
1
  # dsh-plugin-tts bundle: register the Edge TTS voice plugin (host routes + client UI).
2
2
  - insert:
3
3
  - id: tts
4
- name: '@dsh-external/dsh-plugin-tts'
4
+ name: '@memef1f1y/dsh-plugin-tts'
@@ -11,7 +11,7 @@
11
11
  | 项 | 要求 |
12
12
  |---|---|
13
13
  | 环境 | Node ≥ 22;`dsh web` 已启动并加载本插件;浏览器打开 dsh 页面 |
14
- | 代码同步 | 若用 `file:` 本地安装:Windows 先 `Copy-Item lib/* $env:USERPROFILE\.dsh\profiles\web\node_modules\@dsh-external\dsh-plugin-tts\lib\ -Recurse -Force` 再刷新页面;改完 lib/ 后建议重启一次 `dsh web` |
14
+ | 代码同步 | 若用 `file:` 本地安装:Windows 先 `Copy-Item lib/* $env:USERPROFILE\.dsh\profiles\web\node_modules\@memef1f1y\dsh-plugin-tts\lib\ -Recurse -Force` 再刷新页面;改完 lib/ 后建议重启一次 `dsh web` |
15
15
  | 快速确认新代码 | 设置 → 插件 → 语音 → RVC 配置 顶部出现「**RVC 失败时自动改用 Edge TTS**」勾选项 = 已是新版本 |
16
16
  | 音频 | 系统有声、浏览器非静音;先与页面交互一次(自动播放解锁) |
17
17
  | RVC(可选但建议) | 本地模型 .pth + `rvc-server.py`(默认 127.0.0.1:4892)——失败场景需要它「在场但可停止」 |
package/lib/client.js CHANGED
@@ -1,8 +1,8 @@
1
- // @dsh-external/dsh-plugin-tts — Client half (browser bundle).
1
+ // @memef1f1y/dsh-plugin-tts — Client half (browser bundle).
2
2
  // Hand-written in the harness module-loader format; `require` answers the
3
3
  // platform externals (react), everything else is inlined.
4
4
  window.__ModuleLoader__.load({
5
- id: "@dsh-external/dsh-plugin-tts",
5
+ id: "@memef1f1y/dsh-plugin-tts",
6
6
  factory: require => {
7
7
  var module = { exports: {} };
8
8
  var exports = module.exports;
@@ -18,7 +18,7 @@ window.__ModuleLoader__.load({
18
18
 
19
19
  // Bundle build tag: bump on every client fix so a console line proves
20
20
  // WHICH code is actually running (rules out stale-bundle confusion).
21
- const CLIENT_BUILD = "2026-09-22.01-no-starve-overlap";
21
+ const CLIENT_BUILD = "2026-09-26.01-prewarm-skip-toast";
22
22
  const apply = ctx => {
23
23
  const slots = ctx.slots ?? (typeof ctx.get === "function" ? ctx.get("slots") : undefined);
24
24
  if (!slots) throw new Error("[dsh-plugin-tts] slots service unavailable");
@@ -2775,6 +2775,14 @@ window.__ModuleLoader__.load({
2775
2775
  shared.currentJobId = result.jobId || null;
2776
2776
  shared.chunkProgress = { index: 1, total };
2777
2777
  notify();
2778
+ // Prewarm skips happened during /speak (not at /rvc-next time),
2779
+ // so the queue holes below would be walked over silently without
2780
+ // this: surface each one now like any on-demand skip.
2781
+ if (Array.isArray(result.skipped)) {
2782
+ for (const n of result.skipped) {
2783
+ if (typeof onError === "function") onError(t("err.chunkSkip") + " (" + n + "/" + total + ")");
2784
+ }
2785
+ }
2778
2786
  playChunks(result.jobId, result.chunks, total, token, onError);
2779
2787
  return { ok: true, chunked: true, total };
2780
2788
  }
package/lib/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // @dsh-external/dsh-plugin-tts — Host half.
1
+ // @memef1f1y/dsh-plugin-tts — Host half.
2
2
  // Registers webServer routes:
3
3
  // POST /dsh-tts-api/speak { text, voice } -> { url | jobId+chunks } | { error }
4
4
  // GET /dsh-tts-audio/<id> -> audio bytes
@@ -1939,7 +1939,7 @@ function loadRecentErrors() {
1939
1939
  }
1940
1940
  loadRecentErrors();
1941
1941
  // Plugin build identity for the exported log (best-effort; never throws).
1942
- let PLUGIN_META = { name: '@dsh-external/dsh-plugin-tts', version: 'unknown' };
1942
+ let PLUGIN_META = { name: '@memef1f1y/dsh-plugin-tts', version: 'unknown' };
1943
1943
  try {
1944
1944
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
1945
1945
  if (pkg && pkg.version) PLUGIN_META = { name: pkg.name || PLUGIN_META.name, version: String(pkg.version) };
@@ -2283,13 +2283,17 @@ export function apply(ctx) {
2283
2283
  }
2284
2284
  const urls = [];
2285
2285
  let playable = 0;
2286
+ const prewarmSkipped = [];
2286
2287
  for (let i = 0; i < prewarm; i++) {
2287
2288
  job.synthIdx = i + 1;
2288
2289
  const r = await synthChunkAt(job, i);
2289
2290
  // Keep index alignment (a skipped prewarm chunk stays a `false`
2290
2291
  // hole the client walks over in order) instead of compacting.
2292
+ // Skipped prewarm chunks are SILENT otherwise (the skip happened
2293
+ // during /speak, not at /rvc-next time), so report their 1-based
2294
+ // numbers explicitly for the client toast.
2291
2295
  if (r && r.url) { urls.push(r.url); playable++; }
2292
- else urls.push(false);
2296
+ else { urls.push(false); prewarmSkipped.push(i + 1); }
2293
2297
  }
2294
2298
  job.serveIdx = prewarm;
2295
2299
  if (!playable) {
@@ -2303,7 +2307,8 @@ export function apply(ctx) {
2303
2307
  chunks: urls,
2304
2308
  total: parts.length,
2305
2309
  ratio: cal.ratio,
2306
- chunkSec: cal.chunkSec
2310
+ chunkSec: cal.chunkSec,
2311
+ skipped: prewarmSkipped
2307
2312
  });
2308
2313
  return;
2309
2314
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@memef1f1y/dsh-plugin-tts",
3
3
  "description": "Edge TTS 语音大集成插件:消息朗读按钮、自动朗读开关、语音设置面板(Edge TTS / RVC / Index-TTS2 / CosyVoice / Google Cloud TTS)",
4
- "version": "0.6.0",
4
+ "version": "0.6.2",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": ">=22"
package/tests/smoke.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // Smoke test for the Host half of @dsh-external/dsh-plugin-tts.
1
+ // Smoke test for the Host half of @memef1f1y/dsh-plugin-tts.
2
2
  // Uses a fake ctx (webServer captures the routes) and exercises the real
3
3
  // Edge TTS synthesis over the network, the RVC chain against a mock local
4
4
  // RVC inference server, plus the voice-pack registry (mock static server).
@@ -97,6 +97,24 @@ const audioRoute = routes.find((r) => r.kind === 'prefix' && r.path === '/dsh-tt
97
97
 
98
98
  check('plugin registers two routes', speakRoute !== undefined && audioRoute !== undefined);
99
99
 
100
+ // Package identity consistency: the loader entry name (cordis.patch.yml)
101
+ // and the browser bundle id (lib/client.js) must both equal package.json's
102
+ // name. A stale name here ships a package that crashes dsh web at boot
103
+ // (loader imports a package name that isn't installed) — exactly the 0.6.1
104
+ // market-install boot failure.
105
+ {
106
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
107
+ const pkgName = JSON.parse(readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).name;
108
+ const patchText = readFileSync(path.join(repoRoot, 'cordis.patch.yml'), 'utf8');
109
+ const patchName = (patchText.match(/name:\s*['"]([^'"]+)['"]/m) || [])[1] || '';
110
+ const clientText = readFileSync(path.join(repoRoot, 'lib', 'client.js'), 'utf8');
111
+ const clientId = (clientText.match(/^\s*id:\s*"([^"]+)"/m) || [])[1] || '';
112
+ check('cordis.patch.yml entry name matches package.json name',
113
+ patchName === pkgName, `patch=${patchName} pkg=${pkgName}`);
114
+ check('client bundle id matches package.json name',
115
+ clientId === pkgName, `client=${clientId} pkg=${pkgName}`);
116
+ }
117
+
100
118
  if (speakRoute && audioRoute) {
101
119
  const res = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({ text: '你好,这是一个冒烟测试。', voice: 'zh-CN-XiaoxuanNeural' })), mockRes());
102
120
  const parsed = JSON.parse(res.body);
@@ -140,6 +158,40 @@ if (speakRoute && audioRoute) {
140
158
  ep.jobId ? `jobId=${ep.jobId} chunks=${ep.chunks.length} total=${ep.total}` : er.body);
141
159
  }
142
160
 
161
+ // Regression: fastFirst 1/3/2 chunk-swap. After /speak returns chunk 0, the
162
+ // background prewarm of chunk 1 is still in flight — the old shared-nextIdx
163
+ // code served parts[2] as want=1 (or orphaned parts[1] in ready with
164
+ // more=false). Draining immediately must yield indices strictly 0..N-1.
165
+ {
166
+ const orderText = '第一段测试文本,用于验证分段顺序是否正确。'.repeat(9); // ~180 chars -> 3 parts
167
+ const or = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
168
+ text: orderText, voice: 'zh-CN-XiaoxuanNeural', provider: 'edge-tts'
169
+ })), mockRes());
170
+ const op = JSON.parse(or.body);
171
+ const orderNext = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-next');
172
+ if (or.head.code === 200 && typeof op.jobId === 'string' && op.total > 2 && orderNext) {
173
+ const seenIdx = [0];
174
+ const seenUrls = [op.chunks[0]];
175
+ let oMore = true;
176
+ let oGuard = 0;
177
+ while (oMore && oGuard++ < 20) {
178
+ const nr = await call(orderNext, mockReq(`/dsh-tts-api/rvc-next?job=${op.jobId}`), mockRes());
179
+ const np = JSON.parse(nr.body);
180
+ if (np.url || np.skipped) { seenIdx.push(np.index); if (np.url) seenUrls.push(np.url); }
181
+ oMore = !!np.more;
182
+ if (np.done) break;
183
+ }
184
+ const expectIdx = seenIdx.map((_, i) => i);
185
+ check('edge chunked job serves indices strictly 0..N-1 (no swap/drop)',
186
+ JSON.stringify(seenIdx) === JSON.stringify(expectIdx) && seenIdx.length === op.total && oMore === false,
187
+ `got [${seenIdx}] total=${op.total}`);
188
+ check('edge chunked job serves distinct URLs',
189
+ new Set(seenUrls).size === seenUrls.length, `distinct=${new Set(seenUrls).size}/${seenUrls.length}`);
190
+ } else {
191
+ check('edge ordering regression precondition: short chunked job', false, or.body.slice(0, 160));
192
+ }
193
+ }
194
+
143
195
  // M1+ local-piper provider: registered in the abstraction; unconfigured -> graceful
144
196
  // localized error (not a crash)
145
197
  {
@@ -307,6 +359,76 @@ if (speakRoute && audioRoute) {
307
359
  }
308
360
  }
309
361
 
362
+ // ---- prewarm skip surfacing: a prewarm chunk that exhausts retries must
363
+ // be reported (not walked over silently). index-tts2 is used because it
364
+ // has no calibration probe (absolute /tts-task call counting is exact). The mock fails task calls #2 and #3 (= chunk idx1's two
365
+ // attempts), so /speak must return chunks [url, false] + skipped [2],
366
+ // and draining must serve the skip at index 1 in order before continuing.
367
+ {
368
+ let taskCalls = 0;
369
+ const failServer = createServer((req, res) => {
370
+ let body = '';
371
+ req.on('data', (d) => (body += d));
372
+ req.on('end', () => {
373
+ if (req.url === '/health' || req.url === '/api/v1/voices') {
374
+ res.writeHead(200, { 'Content-Type': 'application/json' });
375
+ res.end(JSON.stringify({ ok: true, voices: ['demo.wav'], count: 1 }));
376
+ } else if (req.url === '/api/v1/tts/tasks') {
377
+ taskCalls++;
378
+ if (taskCalls === 2 || taskCalls === 3) {
379
+ res.writeHead(500, { 'Content-Type': 'application/json' });
380
+ res.end(JSON.stringify({ detail: 'mock tts boom' }));
381
+ return;
382
+ }
383
+ res.writeHead(200, { 'Content-Type': 'audio/wav' });
384
+ res.end(miniWav());
385
+ } else {
386
+ res.writeHead(404);
387
+ res.end();
388
+ }
389
+ });
390
+ });
391
+ await new Promise((r) => failServer.listen(0, '127.0.0.1', r));
392
+ try {
393
+ const failPort = failServer.address().port;
394
+ const skipText = '这是一段用于验证预热跳段上报的长文本朗读测试。'.repeat(12);
395
+ const skipRes = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
396
+ text: skipText,
397
+ voice: 'zh-CN-XiaoxuanNeural',
398
+ provider: 'index-tts2',
399
+ custom: { baseUrl: `http://127.0.0.1:${failPort}`, voice: 'demo.wav' }
400
+ })), mockRes());
401
+ const skipParsed = JSON.parse(skipRes.body);
402
+ check('prewarm skip reported in /speak (chunks[1] hole + skipped [2])',
403
+ skipRes.head.code === 200 && Array.isArray(skipParsed.chunks)
404
+ && skipParsed.chunks.length >= 2 && skipParsed.chunks[0] && skipParsed.chunks[1] === false
405
+ && Array.isArray(skipParsed.skipped) && skipParsed.skipped.length === 1 && skipParsed.skipped[0] === 2,
406
+ skipRes.body.slice(0, 200));
407
+ if (typeof skipParsed.jobId === 'string' && Array.isArray(skipParsed.chunks)) {
408
+ const nextRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-next');
409
+ // serveIdx starts past prewarm; the on-demand serves must be the
410
+ // remaining indices strictly in order through to total.
411
+ const seen = [];
412
+ let sMore = true;
413
+ let sGuard = 0;
414
+ while (sMore && sGuard++ < 100) {
415
+ const nr = await call(nextRoute, mockReq(`/dsh-tts-api/rvc-next?job=${skipParsed.jobId}`), mockRes());
416
+ const np = JSON.parse(nr.body);
417
+ if (np.url || np.skipped) seen.push(np.index);
418
+ sMore = !!np.more;
419
+ if (np.done) break;
420
+ }
421
+ const expect = [];
422
+ for (let i = skipParsed.chunks.length; i < skipParsed.total; i++) expect.push(i);
423
+ check('post-prewarm serves continue strictly in order to total',
424
+ JSON.stringify(seen) === JSON.stringify(expect) && sMore === false,
425
+ `got [${seen}] expect [${expect}] total=${skipParsed.total}`);
426
+ }
427
+ } finally {
428
+ await new Promise((r) => failServer.close(r));
429
+ }
430
+ }
431
+
310
432
  // file-discovery proxy route
311
433
  const filesRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-files');
312
434
  const fr = await call(filesRoute, mockReq(`/dsh-tts-api/rvc-files?baseUrl=http://127.0.0.1:${mock.port}&kind=pth`), mockRes());