@memef1f1y/dsh-plugin-tts 0.6.2 → 0.6.4

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/index.mjs CHANGED
@@ -1184,6 +1184,37 @@ function isLatinHeavy(text) {
1184
1184
  */
1185
1185
  const ATOM_RE = /https?:\/\/[^\s,。!?;,!?;]+|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}|\d+(?:\.\d+)+/g;
1186
1186
 
1187
+ /**
1188
+ * Character spans of each part within the source text: [[start,end), …].
1189
+ * Walks the text once, tolerating whitespace differences (trimmed part edges,
1190
+ * collapsed newlines, Cloud's synthetic join gaps) — never message content
1191
+ * aware beyond ordering. Returns null when the parts cannot be walked in
1192
+ * order (then callers fall back to equal division). Pure, unit-tested.
1193
+ * Lets the client map "chunk k" to an exact text slice for scroll-follow
1194
+ * highlight (docs/scroll-follow-v2.md P1-3) instead of guessing by length.
1195
+ */
1196
+ function spanParts(text, parts) {
1197
+ const src = String(text || '');
1198
+ const spans = [];
1199
+ let pos = 0;
1200
+ for (const part of parts) {
1201
+ while (pos < src.length && /\s/.test(src[pos])) pos++;
1202
+ const start = pos;
1203
+ let ok = true;
1204
+ for (const ch of String(part)) {
1205
+ if (/\s/.test(ch)) {
1206
+ while (pos < src.length && /\s/.test(src[pos])) pos++;
1207
+ } else {
1208
+ if (src[pos] !== ch) { ok = false; break; }
1209
+ pos++;
1210
+ }
1211
+ }
1212
+ if (!ok) return null;
1213
+ spans.push([start, pos]);
1214
+ }
1215
+ return spans;
1216
+ }
1217
+
1187
1218
  /** Split text into chunks of <= maxChars, preferring sentence/segment boundaries. */
1188
1219
  function splitText(text, maxChars) {
1189
1220
  const atoms = [];
@@ -1893,6 +1924,9 @@ function ingestSessionEvent(event) {
1893
1924
  // NEVER the message text itself, and NEVER API keys.
1894
1925
  // ---------------------------------------------------------------------------
1895
1926
  const recentErrors = [];
1927
+ // Ring capacity: 20 used to evict real evidence under test traffic (smoke
1928
+ // runs share this file), so chunk-triage entries went missing from exports.
1929
+ const RECENT_CAP = 30;
1896
1930
  // Persisted alongside calibration.json/settings.json so the failure history
1897
1931
  // survives dsh restarts (a fresh update erases in-memory history otherwise).
1898
1932
  // Same schema as the in-memory entries; no message text, no keys.
@@ -1916,14 +1950,14 @@ function recordHostError(entry) {
1916
1950
  const e = Object.assign({ at: Date.now() }, entry);
1917
1951
  if (!e.kind) e.kind = classifyHostError(e.error);
1918
1952
  recentErrors.push(e);
1919
- while (recentErrors.length > 20) recentErrors.shift();
1953
+ while (recentErrors.length > RECENT_CAP) recentErrors.shift();
1920
1954
  saveRecentErrors();
1921
1955
  } catch (err) { /* logging must never break synthesis */ }
1922
1956
  }
1923
1957
  function saveRecentErrors() {
1924
1958
  try {
1925
1959
  mkdirSync(CAL_DIR, { recursive: true });
1926
- writeFileSync(RECENT_FILE, JSON.stringify(recentErrors.slice(-20)));
1960
+ writeFileSync(RECENT_FILE, JSON.stringify(recentErrors.slice(-RECENT_CAP)));
1927
1961
  } catch (e) { /* best-effort */ }
1928
1962
  }
1929
1963
  function loadRecentErrors() {
@@ -1931,7 +1965,7 @@ function loadRecentErrors() {
1931
1965
  if (!existsSync(RECENT_FILE)) return;
1932
1966
  const data = JSON.parse(readFileSync(RECENT_FILE, 'utf8'));
1933
1967
  if (Array.isArray(data)) {
1934
- for (const e of data.slice(-20)) {
1968
+ for (const e of data.slice(-RECENT_CAP)) {
1935
1969
  if (e && typeof e === 'object') recentErrors.push(e);
1936
1970
  }
1937
1971
  }
@@ -1953,6 +1987,11 @@ const WORKER_META = {
1953
1987
  spawnTimeoutMs: 45000,
1954
1988
  retry: 'transient-once-800ms-backoff'
1955
1989
  };
1990
+ // Host chunk-pipeline build tag, surfaced in /diagnose next to worker meta.
1991
+ // Bump on every Host-side playback fix: package.json versions only move on
1992
+ // releases, so without this two different Host builds are indistinguishable
1993
+ // in an exported log (exactly what stalled the 1/3/2 swap triage).
1994
+ const HOST_BUILD = '2026-09-26.02-job-summary';
1956
1995
 
1957
1996
  export function apply(ctx) {
1958
1997
  const webServer = ctx.get('webServer');
@@ -2059,6 +2098,30 @@ export function apply(ctx) {
2059
2098
  return p;
2060
2099
  }
2061
2100
 
2101
+ // One summary line per finished chunked job (drained or cancelled): without
2102
+ // it a serve-order incident leaves zero host trace (the 1/3/2 swap recorded
2103
+ // nothing). Idempotent — re-polls after completion must not duplicate it.
2104
+ // No message text, only counts (same privacy rule as the error ring).
2105
+ function noteJobDone(job, outcome) {
2106
+ if (!job || job.doneLogged) return;
2107
+ job.doneLogged = true;
2108
+ try {
2109
+ const skipped = Object.keys(job.skipErrors || {}).map((k) => Number(k) + 1).sort((a, b) => a - b);
2110
+ recordHostError({
2111
+ provider: job.provider || '?',
2112
+ voice: job.voice,
2113
+ total: job.parts.length,
2114
+ served: job.serveIdx,
2115
+ skipped,
2116
+ stage: 'job',
2117
+ kind: 'info',
2118
+ ms: Date.now() - (job.created || Date.now()),
2119
+ error: 'outcome=' + outcome + ' served=' + job.serveIdx + '/' + job.parts.length +
2120
+ ' skipped=[' + skipped.join(',') + ']'
2121
+ });
2122
+ } catch (e) { /* logging must never break serving */ }
2123
+ }
2124
+
2062
2125
  // Serve the next index in order, awaiting its own synthesis when needed.
2063
2126
  // Never synthesizes serveIdx+1 before serveIdx resolves, so the client —
2064
2127
  // which fills queue[want] in request order — always gets parts in order.
@@ -2069,13 +2132,17 @@ export function apply(ctx) {
2069
2132
  const idx = job.serveIdx;
2070
2133
  if (idx >= job.parts.length) {
2071
2134
  job.finishedAt = job.finishedAt || Date.now();
2135
+ noteJobDone(job, 'drained');
2072
2136
  return { done: true };
2073
2137
  }
2074
2138
  const known = job.urls[idx];
2075
2139
  if (typeof known === 'string' || known === false) {
2076
2140
  job.serveIdx++;
2077
2141
  const more = job.serveIdx < job.parts.length;
2078
- if (!more) job.finishedAt = job.finishedAt || Date.now();
2142
+ if (!more) {
2143
+ job.finishedAt = job.finishedAt || Date.now();
2144
+ noteJobDone(job, 'drained');
2145
+ }
2079
2146
  if (known === false) {
2080
2147
  return { skipped: idx + 1, error: job.skipErrors ? job.skipErrors[idx] || '' : '', more, index: idx };
2081
2148
  }
@@ -2090,7 +2157,10 @@ export function apply(ctx) {
2090
2157
  }
2091
2158
  job.serveIdx++;
2092
2159
  const more = job.serveIdx < job.parts.length;
2093
- if (!more) job.finishedAt = job.finishedAt || Date.now();
2160
+ if (!more) {
2161
+ job.finishedAt = job.finishedAt || Date.now();
2162
+ noteJobDone(job, 'drained');
2163
+ }
2094
2164
  if (r && r.url) return { url: r.url, more, index: idx };
2095
2165
  if (r && r.skipped) return { skipped: idx + 1, error: r.error || '', more, index: idx };
2096
2166
  return { done: true };
@@ -2106,6 +2176,7 @@ export function apply(ctx) {
2106
2176
  if (!job) return false;
2107
2177
  job.done = true;
2108
2178
  job.finishedAt = job.finishedAt || Date.now();
2179
+ noteJobDone(job, 'cancelled');
2109
2180
  jobs.delete(jobId);
2110
2181
  return true;
2111
2182
  }
@@ -2227,6 +2298,9 @@ export function apply(ctx) {
2227
2298
  const parts = provider === 'google-cloud-tts'
2228
2299
  ? splitCloudPackets(text)
2229
2300
  : splitText(text, maxChars);
2301
+ // Block spans for scroll-follow highlight mapping (P1-3); omitted
2302
+ // when unwalkable — the client falls back to equal division.
2303
+ const spans = spanParts(text, parts);
2230
2304
  if (parts.length > 1) {
2231
2305
  const jobId = 'j' + (++jobSeq).toString(36);
2232
2306
  const job = {
@@ -2277,7 +2351,8 @@ export function apply(ctx) {
2277
2351
  chunks: [first.url],
2278
2352
  total: parts.length,
2279
2353
  ratio: cal.ratio,
2280
- chunkSec: cal.chunkSec
2354
+ chunkSec: cal.chunkSec,
2355
+ ...(spans ? { spans } : {})
2281
2356
  });
2282
2357
  return;
2283
2358
  }
@@ -2308,7 +2383,8 @@ export function apply(ctx) {
2308
2383
  total: parts.length,
2309
2384
  ratio: cal.ratio,
2310
2385
  chunkSec: cal.chunkSec,
2311
- skipped: prewarmSkipped
2386
+ skipped: prewarmSkipped,
2387
+ ...(spans ? { spans } : {})
2312
2388
  });
2313
2389
  return;
2314
2390
  }
@@ -2756,8 +2832,9 @@ export function apply(ctx) {
2756
2832
  checks,
2757
2833
  plugin: PLUGIN_META,
2758
2834
  worker: WORKER_META,
2835
+ hostBuild: HOST_BUILD,
2759
2836
  activeJobs,
2760
- recent: recentErrors.slice(-20)
2837
+ recent: recentErrors.slice(-RECENT_CAP)
2761
2838
  });
2762
2839
  } catch (e) {
2763
2840
  writeRouteError(res, e);
@@ -3268,6 +3345,7 @@ export const __test = {
3268
3345
  classifyHostError,
3269
3346
  countWorkerRetries,
3270
3347
  splitText,
3348
+ spanParts,
3271
3349
  splitCloudPackets,
3272
3350
  chunkCal,
3273
3351
  cloudTier,
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.2",
4
+ "version": "0.6.4",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": ">=22"
@@ -324,40 +324,53 @@ if (!failed) {
324
324
  // 4) onBarClick never throws on missing event + reads live shared.speaking
325
325
  const clickOk = /const onBarClick[\s\S]{0,600}?typeof e\.stopPropagation[\s\S]{0,200}?shared\.speaking\) togglePause\(\)/.test(src);
326
326
  check('onBarClick hardened (no-throw + live speaking)', clickOk);
327
- // 5) live scroll-follow: continuous ratio + 250ms heartbeat + user yield
328
- const liveOk = /ttsLiveRatio/.test(src) && /el\.currentTime \/ el\.duration/.test(src) && /chunkStartedAt/.test(src);
329
- const beatOk = /setInterval\(keepVisible,\s*250\)/.test(src);
330
- const yieldOk = /ttsUserScrollAt\.t\s*=\s*Date\.now\(\)/.test(src) && /Date\.now\(\) - ttsUserScrollAt\.t < 5000/.test(src);
331
- check('scroll-follow tracks live ratio', liveOk && beatOk && yieldOk,
332
- `live=${liveOk} beat=${beatOk} yield=${yieldOk}`);
333
- // 5b) self-scroll guard: our own scrollTo() must not refresh the user
334
- // yield window (that froze follow after the first nudge), and the needle
335
- // must track the live ratio (not the fixed text head).
336
- const selfOk = /ttsSelfScroll\.on = true/.test(src) && /if \(ttsSelfScroll\.on\) return/.test(src);
337
- const needleOk = /fullNeedle\.length \* Math\.min\(0\.99/.test(src);
338
- check('follow not frozen by own scroll + live needle', selfOk && needleOk,
339
- `self=${selfOk} needle=${needleOk}`);
327
+ // 5) scroll-follow v2: block-index stepwise signal, visibility-driven
328
+ // minimal scrolling (no polling loop), latch-off with manual resume.
329
+ const stepOk = /const ttsBlockIdx = \(\) =>/.test(src) && /ttsBlockHead/.test(src) &&
330
+ !/ttsLiveRatio/.test(src) && !/chunkStartedAt \|\| Date\.now\(\)\)\) \/ 1000/.test(src);
331
+ const noPoll = !/setInterval\(keepVisible/.test(src) && !/setInterval\(ttsFollowTick/.test(src);
332
+ const latchOk = /ttsFollow\.latched = false/.test(src) && /follow\.resume/.test(src) &&
333
+ !/ttsUserScrollAt\.t < 5000/.test(src);
334
+ check('scroll-follow v2 stepwise signal, no poll, latch-off resume', stepOk && noPoll && latchOk,
335
+ `step=${stepOk} noPoll=${noPoll} latch=${latchOk}`);
336
+ // 5b) self-scroll fence via timestamp + scrollend (covers smooth-scroll
337
+ // duration); takeover sources are wheel/touchmove/pointer-drag only, so
338
+ // host autoscroll during streaming never latches follow off.
339
+ const selfOk = /ttsFollow\.selfUntil = Date\.now\(\) \+ 600/.test(src) && /scrollend/.test(src);
340
+ const takeOk = /onTouchMove/.test(src) && /onPointerMove/.test(src) && /onWheel/.test(src);
341
+ check('follow self-fence + gesture takeover sources', selfOk && takeOk,
342
+ `self=${selfOk} take=${takeOk}`);
340
343
  // 5c) message anchor: button click climbs to the message root (WeakRef),
341
- // text-resolved anchors are cached for auto-read reuse.
344
+ // fingerprint relocate survives re-renders; paragraph hits are never
345
+ // cached as anchors (fragile, single-tick use).
342
346
  const anchorOk = /shared\.readingMsgEl = new WeakRef\(best\)/.test(src) &&
343
347
  /shared\.readingMsgEl \? shared\.readingMsgEl\.deref\(\) : null/.test(src);
344
348
  check('follow uses anchored message element', anchorOk);
345
349
  // 5d) anchor survives re-render (fingerprint relocate) + wrong-element
346
- // guard (never rubber-band to top on a fragment hit).
350
+ // guard (never rubber-band on an absurd hit) + nearest-scroll.
347
351
  const relocOk = /shared\.readingMsgKey = \{ head/.test(src) && /mark\("relocated"\)/.test(src);
348
- const guardOk = /mark\("bad-target"\)/.test(src);
352
+ const guardOk = /mark\("bad-target"\)/.test(src) && /block:"nearest"/.test(src);
349
353
  check('anchor relocates + bad target skipped', relocOk && guardOk,
350
354
  `reloc=${relocOk} guard=${guardOk}`);
355
+ // 5e) reduced-motion respected: follow bails before touching the view.
356
+ check('follow respects prefers-reduced-motion', /ttsReducedMotion\(\)/.test(src) && /mark\("reduced-motion"\)/.test(src));
357
+ // 5f) exact server spans feed the block locator when present (P1-3),
358
+ // equal-division fallback otherwise; spans reset per utterance.
359
+ const spansOk = /shared\.chunkSpans = Array\.isArray\(result\.spans\)/.test(src) &&
360
+ /shared\.chunkSpans = null/.test(src) && /sp\[idx - 1\]\[0\]/.test(src);
361
+ check('follow consumes server block spans with fallback', spansOk);
351
362
  // 6) compact bar: no "n / total" counter in bar text (tooltip only)
352
363
  const noCounter = !/overlay\.reading"\)\) \+ " " \+ idx/.test(src) && !/\("overlay\.paused"\) : t\("overlay\.reading"\)\) \+/.test(src);
353
364
  const pausedOk = /shared\.paused \? t\("overlay\.paused"\) : t\("overlay\.reading"\)(?!\) \+)/.test(src);
354
365
  check('bar compact: paused/reading text only, no counter', noCounter && pausedOk,
355
366
  `noCounter=${noCounter} pausedOk=${pausedOk}`);
356
- // 7) box-shadow sentence highlight removed (ugly)
367
+ // 7) v2 block highlight restored as the visual anchor for follow: soft
368
+ // background, never the old box-shadow style.
369
+ const hlCss = /\.dsh-tts-sentence-active\{[^}]*background/.test(src);
357
370
  const noShadowCss = !/\.dsh-tts-sentence-active\{[^}]*box-shadow/.test(src);
358
- const noAddActive = !/classList\.add\("dsh-tts-sentence-active"\)/.test(src);
359
- check('sentence shadow highlight removed', noShadowCss && noAddActive,
360
- `noShadowCss=${noShadowCss} noAddActive=${noAddActive}`);
371
+ const addActive = /classList\.add\("dsh-tts-sentence-active"\)/.test(src);
372
+ check('block highlight restored (soft bg, no shadow)', hlCss && noShadowCss && addActive,
373
+ `css=${hlCss} noShadow=${noShadowCss} add=${addActive}`);
361
374
  // 8) playback mutual exclusion: playSeq generation guards all audio
362
375
  // callbacks; stop hard-aborts decode via load(); chunk queue is indexed
363
376
  // (no push-on-resolve reorder).
package/tests/smoke.mjs CHANGED
@@ -330,6 +330,23 @@ if (speakRoute && audioRoute) {
330
330
  drained++;
331
331
  }
332
332
  check('rvc-next drains to total chunks', fetched === longParsed.total && more === false, `fetched=${fetched} total=${longParsed.total}`);
333
+ // Job-completion summary: draining to the end must leave exactly one
334
+ // stage=job outcome=drained entry (served==total) in the diagnose log —
335
+ // the trace a serve-order incident would previously have left none of.
336
+ {
337
+ const dgJob = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/diagnose');
338
+ const drJob = await call(dgJob, mockReq('/dsh-tts-api/diagnose', JSON.stringify({})), mockRes());
339
+ const ddJob = JSON.parse(drJob.body);
340
+ const jobHit = Array.isArray(ddJob.recent) && ddJob.recent.find((e) =>
341
+ e && e.stage === 'job' && /outcome=drained/.test(e.error || '') &&
342
+ e.total === longParsed.total && e.served === longParsed.total &&
343
+ Array.isArray(e.skipped) && e.skipped.length === 0);
344
+ check('drained job leaves a served==total summary in diagnose.recent',
345
+ !!jobHit, JSON.stringify((ddJob.recent || []).filter((e) => e && e.stage === 'job')));
346
+ check('diagnose exposes hostBuild tag (which Host code ran)',
347
+ typeof ddJob.hostBuild === 'string' && ddJob.hostBuild.length > 0,
348
+ JSON.stringify(ddJob.hostBuild));
349
+ }
333
350
  check('rvc-next done for unknown job', (await call(nextRoute, mockReq('/dsh-tts-api/rvc-next?job=unknown'), mockRes())).body === JSON.stringify({ done: true, gone: true }));
334
351
  // a prewarmed chunk url must serve wav through the audio route
335
352
  const cRes = await call(audioRoute, mockReq(longParsed.chunks[0]), mockRes());
@@ -404,6 +421,17 @@ if (speakRoute && audioRoute) {
404
421
  && skipParsed.chunks.length >= 2 && skipParsed.chunks[0] && skipParsed.chunks[1] === false
405
422
  && Array.isArray(skipParsed.skipped) && skipParsed.skipped.length === 1 && skipParsed.skipped[0] === 2,
406
423
  skipRes.body.slice(0, 200));
424
+ // P1-3 spans ride the same response: count matches total and the span
425
+ // slices rebuild the sent text (whitespace-insensitive).
426
+ {
427
+ const sps = skipParsed.spans;
428
+ const rebuilt = Array.isArray(sps)
429
+ ? sps.map(([a, b]) => skipText.slice(a, b)).join('').replace(/\s/g, '')
430
+ : null;
431
+ check('chunked /speak carries exact block spans',
432
+ Array.isArray(sps) && sps.length === skipParsed.total && rebuilt === skipText.replace(/\s/g, ''),
433
+ `spans=${sps && sps.length} total=${skipParsed.total}`);
434
+ }
407
435
  if (typeof skipParsed.jobId === 'string' && Array.isArray(skipParsed.chunks)) {
408
436
  const nextRoute = routes.find((r) => r.kind === 'exact' && r.path === '/dsh-tts-api/rvc-next');
409
437
  // serveIdx starts past prewarm; the on-demand serves must be the
@@ -800,6 +828,33 @@ if (speakRoute && audioRoute) {
800
828
  // short text: single chunk, unchanged semantics
801
829
  const r4 = splitText('你好。', 30);
802
830
  check('splitText: short text stays one chunk', r4.length === 1 && r4[0] === '你好。', JSON.stringify(r4));
831
+
832
+ // spanParts (scroll-follow P1-3): per-part char spans walk the source in
833
+ // order; concatenated span slices reproduce the text minus whitespace.
834
+ const spanOf = plugin.__test.spanParts;
835
+ check('__test.spanParts exposed', typeof spanOf === 'function');
836
+ if (typeof spanOf === 'function') {
837
+ const st = '第一句。第二句很长很长,需要切块 Parece que sí。第三句。';
838
+ const sp = splitText(st, 12);
839
+ const ssp = spanOf(st, sp);
840
+ const rebuilt = Array.isArray(ssp)
841
+ ? ssp.map(([a, b]) => st.slice(a, b)).join('').replace(/\s/g, '')
842
+ : null;
843
+ check('spanParts: spans walk in order and rebuild the text',
844
+ Array.isArray(ssp) && ssp.length === sp.length &&
845
+ rebuilt === st.replace(/\s/g, ''),
846
+ `parts=${sp.length} spans=${JSON.stringify(ssp)}`);
847
+ // Cloud synthetic join gaps (latin alnum boundary spaces) match zero
848
+ // source whitespace instead of failing the walk.
849
+ const cl = 'Visit https://example.com/a.b.c now please ok。'.repeat(6);
850
+ const cp = plugin.__test.splitCloudPackets(cl);
851
+ const csp = spanOf(cl, cp);
852
+ check('spanParts: cloud packets walk despite synthetic gaps',
853
+ Array.isArray(csp) && csp.length === cp.length,
854
+ `packets=${cp.length} spans=${csp && csp.length}`);
855
+ check('spanParts: unwalkable parts return null (fallback signal)',
856
+ spanOf('abc', ['zzz']) === null);
857
+ }
803
858
  }
804
859
  }
805
860