@memef1f1y/dsh-plugin-tts 0.6.0 → 0.6.1

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/lib/client.js CHANGED
@@ -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
@@ -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.1",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": ">=22"
package/tests/smoke.mjs CHANGED
@@ -140,6 +140,40 @@ if (speakRoute && audioRoute) {
140
140
  ep.jobId ? `jobId=${ep.jobId} chunks=${ep.chunks.length} total=${ep.total}` : er.body);
141
141
  }
142
142
 
143
+ // Regression: fastFirst 1/3/2 chunk-swap. After /speak returns chunk 0, the
144
+ // background prewarm of chunk 1 is still in flight — the old shared-nextIdx
145
+ // code served parts[2] as want=1 (or orphaned parts[1] in ready with
146
+ // more=false). Draining immediately must yield indices strictly 0..N-1.
147
+ {
148
+ const orderText = '第一段测试文本,用于验证分段顺序是否正确。'.repeat(9); // ~180 chars -> 3 parts
149
+ const or = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
150
+ text: orderText, voice: 'zh-CN-XiaoxuanNeural', provider: 'edge-tts'
151
+ })), mockRes());
152
+ const op = JSON.parse(or.body);
153
+ const orderNext = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-next');
154
+ if (or.head.code === 200 && typeof op.jobId === 'string' && op.total > 2 && orderNext) {
155
+ const seenIdx = [0];
156
+ const seenUrls = [op.chunks[0]];
157
+ let oMore = true;
158
+ let oGuard = 0;
159
+ while (oMore && oGuard++ < 20) {
160
+ const nr = await call(orderNext, mockReq(`/dsh-tts-api/rvc-next?job=${op.jobId}`), mockRes());
161
+ const np = JSON.parse(nr.body);
162
+ if (np.url || np.skipped) { seenIdx.push(np.index); if (np.url) seenUrls.push(np.url); }
163
+ oMore = !!np.more;
164
+ if (np.done) break;
165
+ }
166
+ const expectIdx = seenIdx.map((_, i) => i);
167
+ check('edge chunked job serves indices strictly 0..N-1 (no swap/drop)',
168
+ JSON.stringify(seenIdx) === JSON.stringify(expectIdx) && seenIdx.length === op.total && oMore === false,
169
+ `got [${seenIdx}] total=${op.total}`);
170
+ check('edge chunked job serves distinct URLs',
171
+ new Set(seenUrls).size === seenUrls.length, `distinct=${new Set(seenUrls).size}/${seenUrls.length}`);
172
+ } else {
173
+ check('edge ordering regression precondition: short chunked job', false, or.body.slice(0, 160));
174
+ }
175
+ }
176
+
143
177
  // M1+ local-piper provider: registered in the abstraction; unconfigured -> graceful
144
178
  // localized error (not a crash)
145
179
  {
@@ -307,6 +341,76 @@ if (speakRoute && audioRoute) {
307
341
  }
308
342
  }
309
343
 
344
+ // ---- prewarm skip surfacing: a prewarm chunk that exhausts retries must
345
+ // be reported (not walked over silently). index-tts2 is used because it
346
+ // has no calibration probe (absolute /tts-task call counting is exact). The mock fails task calls #2 and #3 (= chunk idx1's two
347
+ // attempts), so /speak must return chunks [url, false] + skipped [2],
348
+ // and draining must serve the skip at index 1 in order before continuing.
349
+ {
350
+ let taskCalls = 0;
351
+ const failServer = createServer((req, res) => {
352
+ let body = '';
353
+ req.on('data', (d) => (body += d));
354
+ req.on('end', () => {
355
+ if (req.url === '/health' || req.url === '/api/v1/voices') {
356
+ res.writeHead(200, { 'Content-Type': 'application/json' });
357
+ res.end(JSON.stringify({ ok: true, voices: ['demo.wav'], count: 1 }));
358
+ } else if (req.url === '/api/v1/tts/tasks') {
359
+ taskCalls++;
360
+ if (taskCalls === 2 || taskCalls === 3) {
361
+ res.writeHead(500, { 'Content-Type': 'application/json' });
362
+ res.end(JSON.stringify({ detail: 'mock tts boom' }));
363
+ return;
364
+ }
365
+ res.writeHead(200, { 'Content-Type': 'audio/wav' });
366
+ res.end(miniWav());
367
+ } else {
368
+ res.writeHead(404);
369
+ res.end();
370
+ }
371
+ });
372
+ });
373
+ await new Promise((r) => failServer.listen(0, '127.0.0.1', r));
374
+ try {
375
+ const failPort = failServer.address().port;
376
+ const skipText = '这是一段用于验证预热跳段上报的长文本朗读测试。'.repeat(12);
377
+ const skipRes = await call(speakRoute, mockReq('/dsh-tts-api/speak', JSON.stringify({
378
+ text: skipText,
379
+ voice: 'zh-CN-XiaoxuanNeural',
380
+ provider: 'index-tts2',
381
+ custom: { baseUrl: `http://127.0.0.1:${failPort}`, voice: 'demo.wav' }
382
+ })), mockRes());
383
+ const skipParsed = JSON.parse(skipRes.body);
384
+ check('prewarm skip reported in /speak (chunks[1] hole + skipped [2])',
385
+ skipRes.head.code === 200 && Array.isArray(skipParsed.chunks)
386
+ && skipParsed.chunks.length >= 2 && skipParsed.chunks[0] && skipParsed.chunks[1] === false
387
+ && Array.isArray(skipParsed.skipped) && skipParsed.skipped.length === 1 && skipParsed.skipped[0] === 2,
388
+ skipRes.body.slice(0, 200));
389
+ if (typeof skipParsed.jobId === 'string' && Array.isArray(skipParsed.chunks)) {
390
+ const nextRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-next');
391
+ // serveIdx starts past prewarm; the on-demand serves must be the
392
+ // remaining indices strictly in order through to total.
393
+ const seen = [];
394
+ let sMore = true;
395
+ let sGuard = 0;
396
+ while (sMore && sGuard++ < 100) {
397
+ const nr = await call(nextRoute, mockReq(`/dsh-tts-api/rvc-next?job=${skipParsed.jobId}`), mockRes());
398
+ const np = JSON.parse(nr.body);
399
+ if (np.url || np.skipped) seen.push(np.index);
400
+ sMore = !!np.more;
401
+ if (np.done) break;
402
+ }
403
+ const expect = [];
404
+ for (let i = skipParsed.chunks.length; i < skipParsed.total; i++) expect.push(i);
405
+ check('post-prewarm serves continue strictly in order to total',
406
+ JSON.stringify(seen) === JSON.stringify(expect) && sMore === false,
407
+ `got [${seen}] expect [${expect}] total=${skipParsed.total}`);
408
+ }
409
+ } finally {
410
+ await new Promise((r) => failServer.close(r));
411
+ }
412
+ }
413
+
310
414
  // file-discovery proxy route
311
415
  const filesRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-files');
312
416
  const fr = await call(filesRoute, mockReq(`/dsh-tts-api/rvc-files?baseUrl=http://127.0.0.1:${mock.port}&kind=pth`), mockRes());