@leiboss/dsh-tts 0.0.0-stage → 0.3.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -2
  4. package/cordis.patch.yml +4 -0
  5. package/dist/audio/mp3-duration.d.ts +23 -0
  6. package/dist/audio/mp3-duration.d.ts.map +1 -0
  7. package/dist/audio/mp3-duration.js +140 -0
  8. package/dist/audio/mp3-duration.js.map +1 -0
  9. package/dist/audio-route.d.ts +122 -0
  10. package/dist/audio-route.d.ts.map +1 -0
  11. package/dist/audio-route.js +408 -0
  12. package/dist/audio-route.js.map +1 -0
  13. package/dist/cache.d.ts +50 -0
  14. package/dist/cache.d.ts.map +1 -0
  15. package/dist/cache.js +105 -0
  16. package/dist/cache.js.map +1 -0
  17. package/dist/chunked-tts.d.ts +34 -0
  18. package/dist/chunked-tts.d.ts.map +1 -0
  19. package/dist/chunked-tts.js +121 -0
  20. package/dist/chunked-tts.js.map +1 -0
  21. package/dist/chunker.d.ts +19 -0
  22. package/dist/chunker.d.ts.map +1 -0
  23. package/dist/chunker.js +98 -0
  24. package/dist/chunker.js.map +1 -0
  25. package/dist/client.js +90 -0
  26. package/dist/concurrency.d.ts +33 -0
  27. package/dist/concurrency.d.ts.map +1 -0
  28. package/dist/concurrency.js +95 -0
  29. package/dist/concurrency.js.map +1 -0
  30. package/dist/config.d.ts +174 -0
  31. package/dist/config.d.ts.map +1 -0
  32. package/dist/config.js +186 -0
  33. package/dist/config.js.map +1 -0
  34. package/dist/edge-tts.d.ts +74 -0
  35. package/dist/edge-tts.d.ts.map +1 -0
  36. package/dist/edge-tts.js +161 -0
  37. package/dist/edge-tts.js.map +1 -0
  38. package/dist/engine-health.d.ts +108 -0
  39. package/dist/engine-health.d.ts.map +1 -0
  40. package/dist/engine-health.js +170 -0
  41. package/dist/engine-health.js.map +1 -0
  42. package/dist/engines/edge-js.d.ts +51 -0
  43. package/dist/engines/edge-js.d.ts.map +1 -0
  44. package/dist/engines/edge-js.js +98 -0
  45. package/dist/engines/edge-js.js.map +1 -0
  46. package/dist/errors.d.ts +66 -0
  47. package/dist/errors.d.ts.map +1 -0
  48. package/dist/errors.js +110 -0
  49. package/dist/errors.js.map +1 -0
  50. package/dist/fallback.d.ts +69 -0
  51. package/dist/fallback.d.ts.map +1 -0
  52. package/dist/fallback.js +89 -0
  53. package/dist/fallback.js.map +1 -0
  54. package/dist/index.d.ts +72 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +723 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/probe.d.ts +42 -0
  59. package/dist/probe.d.ts.map +1 -0
  60. package/dist/probe.js +63 -0
  61. package/dist/probe.js.map +1 -0
  62. package/dist/provider.d.ts +33 -0
  63. package/dist/provider.d.ts.map +1 -0
  64. package/dist/provider.js +7 -0
  65. package/dist/provider.js.map +1 -0
  66. package/dist/register-service.d.ts +25 -0
  67. package/dist/register-service.d.ts.map +1 -0
  68. package/dist/register-service.js +33 -0
  69. package/dist/register-service.js.map +1 -0
  70. package/dist/sapi-tts.d.ts +29 -0
  71. package/dist/sapi-tts.d.ts.map +1 -0
  72. package/dist/sapi-tts.js +131 -0
  73. package/dist/sapi-tts.js.map +1 -0
  74. package/dist/self-check.d.ts +32 -0
  75. package/dist/self-check.d.ts.map +1 -0
  76. package/dist/self-check.js +75 -0
  77. package/dist/self-check.js.map +1 -0
  78. package/dist/service.d.ts +119 -0
  79. package/dist/service.d.ts.map +1 -0
  80. package/dist/service.js +404 -0
  81. package/dist/service.js.map +1 -0
  82. package/dist/voices.d.ts +21 -0
  83. package/dist/voices.d.ts.map +1 -0
  84. package/dist/voices.js +74 -0
  85. package/dist/voices.js.map +1 -0
  86. package/icon.svg +6 -0
  87. package/locale/en.json +6 -0
  88. package/locale/zh.json +6 -0
  89. package/package.json +93 -3
package/dist/index.js ADDED
@@ -0,0 +1,723 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools';
2
+ import { AudioRoute, } from "./audio-route.js";
3
+ import { AUDIO_FORMAT, cacheKey, findCachedFile, readCacheMetadata, writeCacheMetadata, } from "./cache.js";
4
+ import { synthesizeChunked } from "./chunked-tts.js";
5
+ import { ConcurrencyLimiter } from "./concurrency.js";
6
+ import { Config, DEFAULT_RATE, isTtsEngine, requireSynthesisConfig, resolveConfig, } from "./config.js";
7
+ import { EngineHealthRegistry } from "./engine-health.js";
8
+ import { synthesize, } from "./edge-tts.js";
9
+ import { ErrorCode, TtsError } from "./errors.js";
10
+ import { isFallbackEligible, runFallback } from "./fallback.js";
11
+ import { selfCheck } from "./self-check.js";
12
+ import { probeEndpoint } from "./probe.js";
13
+ import { AVAILABLE_VOICES } from "./voices.js";
14
+ import { mountTtsService } from "./register-service.js";
15
+ export { Config } from "./config.js";
16
+ export { resolveConfig, DEFAULT_ENGINE } from "./config.js";
17
+ export { TtsService } from "./service.js";
18
+ export { ErrorCode, TtsError } from "./errors.js";
19
+ export { cacheKey, findCachedFile } from "./cache.js";
20
+ export { runProcess, synthesize } from "./edge-tts.js";
21
+ export { selfCheck } from "./self-check.js";
22
+ export { AUDIO_ROUTE_PREFIX, AudioRoute } from "./audio-route.js";
23
+ export { DEFAULT_CHUNK_SIZE, splitText } from "./chunker.js";
24
+ export { synthesizeChunked } from "./chunked-tts.js";
25
+ export { ConcurrencyLimiter } from "./concurrency.js";
26
+ export { isFallbackEligible, runFallback } from "./fallback.js";
27
+ export { isSapiSupported, sapiRate, synthesizeSapi } from "./sapi-tts.js";
28
+ export { AVAILABLE_VOICES, VOICES } from "./voices.js";
29
+ export const name = 'dsh-tts';
30
+ /** The registry must exist before `apply` runs. */
31
+ export const inject = ['tools'];
32
+ /**
33
+ * Edge-TTS voice names are `<lang>-<REGION>-<Name>Neural`, with an optional
34
+ * lowercase sub-region segment (for example `zh-CN-liaoning-XiaobeiNeural`).
35
+ */
36
+ const VOICE_PATTERN = /^[a-z]{2,3}-[A-Z]{2,3}(?:-[a-z0-9]+)?-[A-Za-z0-9]+Neural$/;
37
+ /** Edge-TTS rates are signed percentages such as `+0%` or `-10%`. */
38
+ const RATE_PATTERN = /^[+-]\d{1,3}%$/;
39
+ /** Edge-TTS pitches are signed frequencies such as `+10Hz` or `-20Hz`. */
40
+ const PITCH_PATTERN = /^[+-]\d{1,3}Hz$/;
41
+ /** Edge-TTS volumes are signed percentages such as `+20%` or `-10%`. */
42
+ const VOLUME_PATTERN = /^[+-]\d{1,3}%$/;
43
+ /** Pitch Edge-TTS uses when the call omits one. */
44
+ export const DEFAULT_PITCH = '+0Hz';
45
+ /** Volume Edge-TTS uses when the call omits one. */
46
+ export const DEFAULT_VOLUME = '+0%';
47
+ /**
48
+ * Canonical tool value. Only the fields declared in `output.schema` may
49
+ * appear; `browserUrl` is omitted when the host offers no web route and
50
+ * `engine` is omitted when the default Edge-TTS path produced the file.
51
+ */
52
+ function canonicalAudio(audio, audioRoute, engine, engineHealth) {
53
+ const browserUrl = audioRoute?.urlFor(audio.filePath);
54
+ return {
55
+ filePath: audio.filePath,
56
+ format: audio.format,
57
+ ...(browserUrl === undefined ? {} : { browserUrl }),
58
+ ...(engine === undefined ? {} : { engine }),
59
+ ...(engineHealth === undefined ? {} : { engineHealth }),
60
+ };
61
+ }
62
+ /**
63
+ * Optional health descriptor attached only when it carries information: a
64
+ * non-default engine, a non-healthy engine, or a skipped engine. The plain
65
+ * Edge-TTS success path therefore keeps its historical `{filePath, format}`
66
+ * shape.
67
+ */
68
+ function describeEngineHealth(usedEngine, usedEngineState, skipped) {
69
+ if (usedEngine === 'edge-tts' &&
70
+ usedEngineState === 'healthy' &&
71
+ skipped.length === 0) {
72
+ return undefined;
73
+ }
74
+ return {
75
+ usedEngine,
76
+ usedEngineState,
77
+ ...(skipped.length === 0 ? {} : { skippedEngines: [...skipped] }),
78
+ };
79
+ }
80
+ /**
81
+ * Condense per-engine failures for the `speechSynthesis` fallback. Absolute
82
+ * paths are stripped and each reason is truncated: the field is user-visible
83
+ * and must never leak temp paths, tokens or the source text.
84
+ */
85
+ function summarizeAttempts(attempts) {
86
+ return attempts
87
+ .map((attempt) => {
88
+ const reason = attempt.reason
89
+ .replace(/(?:[A-Za-z]:[\\/]|\/)[^\s'"`;:]+/g, '[path]')
90
+ .replace(/\s+/g, ' ')
91
+ .trim();
92
+ const clipped = reason.length > 160 ? `${reason.slice(0, 157)}...` : reason;
93
+ return `${attempt.engine}: ${clipped}`;
94
+ })
95
+ .join('; ');
96
+ }
97
+ function requireText(text) {
98
+ if (text.trim().length === 0) {
99
+ throw new TtsError(ErrorCode.EmptyText, '`text` must be a non-empty string.', 'Pass the text that should be spoken; whitespace-only input is rejected.');
100
+ }
101
+ }
102
+ function requireVoice(voice) {
103
+ if (!VOICE_PATTERN.test(voice)) {
104
+ throw new TtsError(ErrorCode.InvalidVoice, `Invalid Edge-TTS voice ${JSON.stringify(voice)}.`, 'Use a name such as `zh-CN-XiaoxiaoNeural`; run `edge-tts --list-voices` to list valid voices.');
105
+ }
106
+ }
107
+ function requireRate(rate) {
108
+ if (!RATE_PATTERN.test(rate)) {
109
+ throw new TtsError(ErrorCode.InvalidRate, `Invalid Edge-TTS rate ${JSON.stringify(rate)}.`, 'Use a signed percentage such as `+0%`, `-10%`, or `+25%`.');
110
+ }
111
+ }
112
+ function requirePitch(pitch) {
113
+ if (!PITCH_PATTERN.test(pitch)) {
114
+ throw new TtsError(ErrorCode.InvalidPitch, `Invalid Edge-TTS pitch ${JSON.stringify(pitch)}.`, 'Use a signed frequency such as `+0Hz`, `+10Hz`, or `-20Hz`.');
115
+ }
116
+ }
117
+ function requireVolume(volume) {
118
+ if (!VOLUME_PATTERN.test(volume)) {
119
+ throw new TtsError(ErrorCode.InvalidVolume, `Invalid Edge-TTS volume ${JSON.stringify(volume)}.`, 'Use a signed percentage such as `+0%`, `+20%`, or `-10%`.');
120
+ }
121
+ }
122
+ function generateDescription() {
123
+ return [
124
+ 'Synthesize speech from text with Edge-TTS and save it as a durable audio file.',
125
+ 'Call this when the user asks for spoken output as a file: read this aloud, generate a voiceover, make an audio version of the text.',
126
+ 'Returns the absolute file path and audio format. The model cannot hear the audio; use the path directly.',
127
+ 'It does NOT play audio, transcribe audio, clone or modify a voice, post-process audio, or generate music.',
128
+ ].join(' ');
129
+ }
130
+ function selfCheckDescription() {
131
+ return [
132
+ 'Diagnose the local TTS runtime without synthesizing audio.',
133
+ 'Reports whether the edge-tts binary is available, whether the DSH sandbox permits creating the subprocess, and whether the output directory is writable.',
134
+ 'Call this after installation, or when tts_generate fails with EDGE_TTS_NOT_FOUND, SPAWN_EPERM, or OUTPUT_DIR_NOT_WRITABLE.',
135
+ ].join(' ');
136
+ }
137
+ function probeDescription() {
138
+ return [
139
+ 'Network diagnostic only: opens one TCP connection to speech.platform.bing.com:443 (1.5 s timeout by default, 5 s maximum) and reports whether it is reachable.',
140
+ 'It does not synthesize audio, does not send a WebSocket handshake, and does not consume Edge-TTS quota.',
141
+ 'The probe always connects directly, even when a proxy is configured; a false result can simply mean the machine needs a proxy. In that case check the `proxy` config instead of assuming the plugin is broken.',
142
+ 'tts_self_check stays fully offline; call this tool only when you need to test connectivity.',
143
+ ].join(' ');
144
+ }
145
+ /**
146
+ * Build both tool definitions from one resolved config. Exported so tests can
147
+ * exercise the real `execute` bodies with an injected spawn implementation.
148
+ */
149
+ export function createTtsTools(config, deps = {}) {
150
+ const limiter = deps.limiter ??
151
+ new ConcurrencyLimiter({
152
+ maxConcurrent: config.maxConcurrent,
153
+ queueSize: config.queueSize,
154
+ queueTimeoutMs: config.queueTimeoutMs,
155
+ });
156
+ const inFlight = new Map();
157
+ // Process-local health only; a plugin restart starts every engine healthy.
158
+ const health = new EngineHealthRegistry(config.engineHealth, deps.now ?? (() => new Date()));
159
+ /**
160
+ * Wait for a shared synthesis with a per-caller abort. A caller that gives
161
+ * up only removes itself; the underlying task is aborted when the last
162
+ * waiter leaves.
163
+ */
164
+ const waitForGroup = (group, signal) => new Promise((resolve, reject) => {
165
+ group.waiters += 1;
166
+ let done = false;
167
+ const finish = (settle) => {
168
+ if (done)
169
+ return;
170
+ done = true;
171
+ signal?.removeEventListener('abort', onAbort);
172
+ group.waiters -= 1;
173
+ if (group.waiters === 0 && !group.settled)
174
+ group.controller.abort();
175
+ settle();
176
+ };
177
+ const onAbort = () => {
178
+ finish(() => reject(new TtsError(ErrorCode.Aborted, 'TTS synthesis was aborted while waiting for a shared request.')));
179
+ };
180
+ if (signal?.aborted) {
181
+ onAbort();
182
+ return;
183
+ }
184
+ signal?.addEventListener('abort', onAbort, { once: true });
185
+ group.promise.then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
186
+ });
187
+ const cachedEngine = async (filePath) => {
188
+ const metadata = await readCacheMetadata(filePath);
189
+ if (metadata === undefined || metadata.engine === 'edge-tts') {
190
+ return undefined;
191
+ }
192
+ return metadata.engine;
193
+ };
194
+ const recordMetadata = async (audio, engine, settings) => {
195
+ await writeCacheMetadata(audio.filePath, {
196
+ engine,
197
+ ...settings,
198
+ createdAt: new Date().toISOString(),
199
+ });
200
+ };
201
+ /**
202
+ * A cache hit used no engine, so report the state of whichever engine
203
+ * produced the file (or Edge-TTS for legacy entries without a sidecar).
204
+ */
205
+ const healthForCacheHit = (cachedEngineName) => {
206
+ const usedEngine = cachedEngineName ?? 'edge-tts';
207
+ const state = health.stateOf(isTtsEngine(usedEngine) ? usedEngine : 'edge-tts');
208
+ return describeEngineHealth(usedEngine, state, []);
209
+ };
210
+ const renderOutcome = (outcome, speech) => {
211
+ if (outcome.kind === 'speechSynthesis') {
212
+ return {
213
+ fallback: 'speechSynthesis',
214
+ ...speech,
215
+ ...(outcome.reason === undefined
216
+ ? {}
217
+ : { fallbackReason: outcome.reason }),
218
+ };
219
+ }
220
+ return canonicalAudio(outcome.audio, deps.audioRoute, outcome.engine, outcome.engineHealth);
221
+ };
222
+ /**
223
+ * Human-readable tool output. The JSON contract keeps `browserUrl` relative;
224
+ * the absolute link is rendered only when the host advertised an origin, so
225
+ * headless runs simply omit it instead of guessing a host.
226
+ */
227
+ const renderGeneratedAudio = (value) => {
228
+ if (value.fallback === 'speechSynthesis') {
229
+ const base = `${ErrorCode.SpeechSynthesisRequired}: satisfied without a host audio file. Read the returned text with the browser speechSynthesis API. If a file was expected, install or repair edge-tts and retry.`;
230
+ return value.fallbackReason === undefined
231
+ ? base
232
+ : `${base}\nReason: ${value.fallbackReason}`;
233
+ }
234
+ const filePath = value.filePath ?? '';
235
+ const format = value.format ?? '';
236
+ const absolute = (() => {
237
+ try {
238
+ return deps.audioRoute?.absoluteUrlFor?.(filePath);
239
+ }
240
+ catch {
241
+ return undefined;
242
+ }
243
+ })();
244
+ const header = `Generated audio: \`${filePath}\` (${format})`;
245
+ if (absolute !== undefined) {
246
+ return `${header}\n[▶ 播放](${absolute})`;
247
+ }
248
+ if (value.browserUrl !== undefined) {
249
+ return `Generated audio: ${filePath} (${format})\nPlayback URL: ${value.browserUrl}`;
250
+ }
251
+ return header;
252
+ };
253
+ const generate = defineTool({
254
+ name: 'tts_generate',
255
+ description: generateDescription(),
256
+ parameters: {
257
+ text: {
258
+ type: 'string',
259
+ required: true,
260
+ description: 'Text to speak. Must contain at least one non-whitespace character.',
261
+ },
262
+ voice: {
263
+ type: 'string',
264
+ description: `Edge-TTS voice name, for example zh-CN-XiaoxiaoNeural. Defaults to ${config.defaultVoice}.`,
265
+ },
266
+ rate: {
267
+ type: 'string',
268
+ description: `Speech rate as a signed percentage, for example +0% or -10%. Defaults to ${DEFAULT_RATE}.`,
269
+ },
270
+ pitch: {
271
+ type: 'string',
272
+ description: `Pitch adjustment as a signed frequency, for example +10Hz or -20Hz. Defaults to ${DEFAULT_PITCH}.`,
273
+ },
274
+ volume: {
275
+ type: 'string',
276
+ description: `Volume adjustment as a signed percentage, for example +20% or -10%. Defaults to ${DEFAULT_VOLUME}.`,
277
+ },
278
+ },
279
+ output: {
280
+ schema: {
281
+ type: 'object',
282
+ properties: {
283
+ filePath: {
284
+ type: 'string',
285
+ description: 'Absolute path of the generated audio file. Present unless `fallback` is `speechSynthesis`.',
286
+ },
287
+ format: {
288
+ type: 'string',
289
+ description: 'Audio container format, for example mp3. Present unless `fallback` is `speechSynthesis`.',
290
+ },
291
+ browserUrl: {
292
+ type: 'string',
293
+ description: 'Same-origin URL that plays this audio in the DSH Web UI; absent when no web route is available.',
294
+ },
295
+ engine: {
296
+ type: 'string',
297
+ description: 'Engine that produced the audio when it was not the default Edge-TTS path, for example `sapi`.',
298
+ },
299
+ fallback: {
300
+ type: 'string',
301
+ description: '`speechSynthesis` when no host audio file was produced and the caller should speak `text` with the browser API.',
302
+ },
303
+ text: {
304
+ type: 'string',
305
+ description: 'Original text, returned only when `fallback` is `speechSynthesis`.',
306
+ },
307
+ voice: {
308
+ type: 'string',
309
+ description: 'Effective voice, returned only when `fallback` is `speechSynthesis`.',
310
+ },
311
+ rate: {
312
+ type: 'string',
313
+ description: 'Effective rate, returned only when `fallback` is `speechSynthesis`.',
314
+ },
315
+ fallbackReason: {
316
+ type: 'string',
317
+ description: 'Condensed per-engine failure summary, returned only when `fallback` is `speechSynthesis`. Contains engine names, error codes and truncated stderr; never user text, tokens or absolute paths.',
318
+ },
319
+ engineHealth: {
320
+ type: 'object',
321
+ additionalProperties: false,
322
+ description: 'Optional in-memory health snapshot for this call. Omitted on the plain healthy Edge-TTS path. Reflects the last observed attempt, not a live network probe.',
323
+ properties: {
324
+ usedEngine: {
325
+ type: 'string',
326
+ required: true,
327
+ description: 'Engine that produced this audio, for example `edge-tts` or `sapi`.',
328
+ },
329
+ usedEngineState: {
330
+ type: 'string',
331
+ required: true,
332
+ enum: ['healthy', 'degraded', 'open'],
333
+ description: 'Health of `usedEngine` just before this attempt succeeded.',
334
+ },
335
+ skippedEngines: {
336
+ type: 'array',
337
+ items: { type: 'string' },
338
+ description: 'Engines skipped for this attempt because their cooldown circuit was open.',
339
+ },
340
+ },
341
+ },
342
+ },
343
+ additionalProperties: false,
344
+ },
345
+ render: (_args, value) => [
346
+ {
347
+ type: 'text',
348
+ text: renderGeneratedAudio(value),
349
+ },
350
+ ],
351
+ },
352
+ async execute(args, exec) {
353
+ const text = args.text;
354
+ requireText(text);
355
+ const voice = args.voice?.trim() || config.defaultVoice;
356
+ requireVoice(voice);
357
+ const rate = args.rate?.trim() || DEFAULT_RATE;
358
+ requireRate(rate);
359
+ const pitch = args.pitch?.trim() || DEFAULT_PITCH;
360
+ requirePitch(pitch);
361
+ const volume = args.volume?.trim() || DEFAULT_VOLUME;
362
+ requireVolume(volume);
363
+ const { outputDir, timeoutMs, totalTimeoutMs, chunkSize, chunkingEnabled, fallbackEnabled, fallbackOrder, } = requireSynthesisConfig(config);
364
+ const hash = cacheKey(text, voice, rate);
365
+ const settings = { voice, rate, pitch, volume };
366
+ const cached = await findCachedFile(outputDir, hash);
367
+ if (cached !== undefined) {
368
+ const engine = await cachedEngine(cached);
369
+ return canonicalAudio({ filePath: cached, format: AUDIO_FORMAT }, deps.audioRoute, engine, healthForCacheHit(engine));
370
+ }
371
+ // Identical in-flight requests share one synthesis; they never consume
372
+ // another slot. Different pitch/volume share the key by design, matching
373
+ // the documented cache behaviour.
374
+ const existing = inFlight.get(hash);
375
+ if (existing !== undefined) {
376
+ return renderOutcome(await waitForGroup(existing, exec.signal), {
377
+ text,
378
+ voice,
379
+ rate,
380
+ });
381
+ }
382
+ const controller = new AbortController();
383
+ const task = limiter.run(async () => {
384
+ // A request that waited for a slot may find the file already produced.
385
+ const ready = await findCachedFile(outputDir, hash);
386
+ if (ready !== undefined) {
387
+ const engine = await cachedEngine(ready);
388
+ const engineHealth = healthForCacheHit(engine);
389
+ return {
390
+ kind: 'audio',
391
+ audio: { filePath: ready, format: AUDIO_FORMAT },
392
+ ...(engine === undefined ? {} : { engine }),
393
+ ...(engineHealth === undefined ? {} : { engineHealth }),
394
+ };
395
+ }
396
+ const primary = async () => chunkingEnabled && text.length > chunkSize
397
+ ? await synthesizeChunked(text, {
398
+ voice,
399
+ rate,
400
+ pitch,
401
+ volume,
402
+ proxy: config.proxy,
403
+ binary: config.edgeTtsBinary,
404
+ outputDir,
405
+ hash,
406
+ timeoutMs,
407
+ totalTimeoutMs,
408
+ chunkSize,
409
+ signal: controller.signal,
410
+ spawnImpl: deps.spawnImpl,
411
+ now: deps.now?.(),
412
+ interChunkDelayMs: deps.chunkDelayMs,
413
+ })
414
+ : await synthesize(text, {
415
+ voice,
416
+ rate,
417
+ pitch,
418
+ volume,
419
+ proxy: config.proxy,
420
+ binary: config.edgeTtsBinary,
421
+ outputDir,
422
+ hash,
423
+ timeoutMs,
424
+ signal: controller.signal,
425
+ spawnImpl: deps.spawnImpl,
426
+ now: deps.now?.(),
427
+ });
428
+ // Health only removes engines from this attempt; the order below is
429
+ // still the configured chain, and an all-open chain forces its last
430
+ // engine through instead of returning an empty result.
431
+ const chain = fallbackEnabled
432
+ ? ['edge-tts', ...fallbackOrder.filter((name) => name !== 'edge-tts')]
433
+ : ['edge-tts'];
434
+ const plan = health.plan(chain);
435
+ const skipped = [...plan.skip];
436
+ const primarySkipped = plan.skip.has('edge-tts');
437
+ const runChain = async (primaryError) => {
438
+ const started = Date.now();
439
+ const outcome = await runFallback({
440
+ text,
441
+ rate,
442
+ outputDir,
443
+ hash,
444
+ timeoutMs,
445
+ fallbackOrder,
446
+ signal: controller.signal,
447
+ now: deps.now?.(),
448
+ ...(primaryError === undefined ? {} : { primaryError }),
449
+ ...(primarySkipped
450
+ ? {
451
+ primaryReason: 'primary engine skipped: circuit open (cooldown)',
452
+ }
453
+ : {}),
454
+ skip: plan.skip,
455
+ }, {
456
+ platform: deps.platform,
457
+ spawnImpl: deps.spawnImpl,
458
+ powershellBinary: deps.powershellBinary,
459
+ ffmpegBinary: deps.ffmpegBinary,
460
+ synthesizeSapiImpl: deps.sapiSynthesize,
461
+ health,
462
+ });
463
+ deps.log?.(`fallback engine=${outcome.engine} elapsedMs=${Date.now() - started}`);
464
+ if (outcome.kind === 'speechSynthesis') {
465
+ return {
466
+ kind: 'speechSynthesis',
467
+ reason: summarizeAttempts(outcome.attempts),
468
+ };
469
+ }
470
+ await recordMetadata(outcome.audio, outcome.engine, settings);
471
+ const engineHealth = describeEngineHealth(outcome.engine, outcome.engineState, skipped);
472
+ return {
473
+ kind: 'audio',
474
+ audio: outcome.audio,
475
+ engine: outcome.engine,
476
+ ...(engineHealth === undefined ? {} : { engineHealth }),
477
+ };
478
+ };
479
+ if (primarySkipped)
480
+ return await runChain();
481
+ try {
482
+ const audio = await primary();
483
+ const engineState = health.stateOf('edge-tts');
484
+ health.recordSuccess('edge-tts');
485
+ await recordMetadata(audio, 'edge-tts', settings);
486
+ const engineHealth = describeEngineHealth('edge-tts', engineState, skipped);
487
+ return {
488
+ kind: 'audio',
489
+ audio,
490
+ ...(engineHealth === undefined ? {} : { engineHealth }),
491
+ };
492
+ }
493
+ catch (error) {
494
+ health.recordFailure('edge-tts', error);
495
+ // Validation, config, cache, and abort errors never fall back.
496
+ if (!fallbackEnabled || !isFallbackEligible(error))
497
+ throw error;
498
+ return await runChain(error);
499
+ }
500
+ }, controller.signal);
501
+ const group = {
502
+ promise: task,
503
+ controller,
504
+ waiters: 0,
505
+ settled: false,
506
+ };
507
+ task.then(() => {
508
+ group.settled = true;
509
+ if (inFlight.get(hash) === group)
510
+ inFlight.delete(hash);
511
+ }, () => {
512
+ group.settled = true;
513
+ if (inFlight.get(hash) === group)
514
+ inFlight.delete(hash);
515
+ });
516
+ inFlight.set(hash, group);
517
+ return renderOutcome(await waitForGroup(group, exec.signal), {
518
+ text,
519
+ voice,
520
+ rate,
521
+ });
522
+ },
523
+ });
524
+ const check = defineTool({
525
+ name: 'tts_self_check',
526
+ description: selfCheckDescription(),
527
+ parameters: {},
528
+ output: {
529
+ schema: {
530
+ type: 'object',
531
+ properties: {
532
+ edgeTtsAvailable: {
533
+ type: 'boolean',
534
+ required: true,
535
+ description: 'Whether the edge-tts binary could be started.',
536
+ },
537
+ edgeTtsVersion: {
538
+ type: 'string',
539
+ description: 'Reported Edge-TTS version, when the CLI exposes one.',
540
+ },
541
+ spawnAllowed: {
542
+ type: 'boolean',
543
+ required: true,
544
+ description: 'Whether the sandbox permits creating the subprocess.',
545
+ },
546
+ outputDirWritable: {
547
+ type: 'boolean',
548
+ required: true,
549
+ description: 'Whether the effective output directory exists and is writable.',
550
+ },
551
+ dshHome: {
552
+ type: 'string',
553
+ required: true,
554
+ description: 'Value of DSH_HOME, or an empty string when unset.',
555
+ },
556
+ proxyConfigured: {
557
+ type: 'boolean',
558
+ required: true,
559
+ description: 'Whether a proxy is configured for Edge-TTS. Connectivity is not tested; call tts_probe_network for that.',
560
+ },
561
+ availableVoices: {
562
+ type: 'array',
563
+ items: { type: 'string' },
564
+ required: true,
565
+ description: 'Static list of documented voice names; no network query is performed.',
566
+ },
567
+ engineHealth: {
568
+ type: 'object',
569
+ required: true,
570
+ additionalProperties: true,
571
+ description: 'In-memory per-engine health snapshot keyed by engine name, with `state` and `consecutiveFailures` (plus `lastErrorCode` after a failure). It is not a live probe: tts_self_check performs no network requests.',
572
+ },
573
+ },
574
+ additionalProperties: false,
575
+ },
576
+ render: (_args, value) => [
577
+ { type: 'text', text: JSON.stringify(value, null, 2) },
578
+ ],
579
+ },
580
+ async execute(_args, exec) {
581
+ const result = await selfCheck({
582
+ config,
583
+ signal: exec.signal,
584
+ spawnImpl: deps.spawnImpl,
585
+ engineHealth: () => health.snapshot(),
586
+ });
587
+ return {
588
+ ...result,
589
+ availableVoices: [...AVAILABLE_VOICES],
590
+ engineHealth: result.engineHealth ?? health.snapshot(),
591
+ };
592
+ },
593
+ });
594
+ const probe = defineTool({
595
+ name: 'tts_probe_network',
596
+ description: probeDescription(),
597
+ parameters: {
598
+ timeoutMs: {
599
+ type: 'number',
600
+ description: 'TCP connect timeout in milliseconds. Defaults to 1500; values are clamped to 1..5000.',
601
+ },
602
+ },
603
+ output: {
604
+ schema: {
605
+ type: 'object',
606
+ properties: {
607
+ host: {
608
+ type: 'string',
609
+ required: true,
610
+ description: 'Probed host; always speech.platform.bing.com.',
611
+ },
612
+ port: {
613
+ type: 'number',
614
+ required: true,
615
+ description: 'Probed TCP port; always 443.',
616
+ },
617
+ reachable: {
618
+ type: 'boolean',
619
+ required: true,
620
+ description: 'Whether the TCP connection completed before the timeout.',
621
+ },
622
+ elapsedMs: {
623
+ type: 'number',
624
+ required: true,
625
+ description: 'Wall-clock time spent on the probe, in milliseconds.',
626
+ },
627
+ proxyConfigured: {
628
+ type: 'boolean',
629
+ required: true,
630
+ description: 'Whether a proxy is configured. The probe itself always connects directly.',
631
+ },
632
+ },
633
+ additionalProperties: false,
634
+ },
635
+ render: (_args, value) => [
636
+ { type: 'text', text: JSON.stringify(value, null, 2) },
637
+ ],
638
+ },
639
+ async execute(args, exec) {
640
+ const result = await probeEndpoint({
641
+ timeoutMs: args.timeoutMs,
642
+ signal: exec.signal,
643
+ connectImpl: deps.probeConnect,
644
+ });
645
+ return { ...result, proxyConfigured: config.proxy !== undefined };
646
+ },
647
+ });
648
+ return [generate, check, probe];
649
+ }
650
+ /**
651
+ * Read an optional Cordis service without requiring a static dependency.
652
+ */
653
+ function serviceOf(ctx, name) {
654
+ const getter = ctx.get;
655
+ return typeof getter === 'function'
656
+ ? getter.call(ctx, name)
657
+ : undefined;
658
+ }
659
+ /**
660
+ * Mount both tools. Registration is fiber-owned, so unloading the plugin
661
+ * unregisters them without explicit cleanup.
662
+ */
663
+ export function apply(ctx, config) {
664
+ const resolved = resolveConfig(config);
665
+ const logger = typeof ctx.logger === 'function' ? ctx.logger('@leiboss/dsh-tts') : undefined;
666
+ const debug = (message) => {
667
+ logger?.debug(message);
668
+ };
669
+ const connection = serviceOf(ctx, 'connection');
670
+ const audioRoute = resolved.outputDir
671
+ ? new AudioRoute({
672
+ outputDir: resolved.outputDir,
673
+ trustedHosts: resolved.trustedHosts,
674
+ connection,
675
+ debug,
676
+ })
677
+ : undefined;
678
+ // The Web host provides `webServer`. Older hosts exposed the same router as
679
+ // `httpServer`; if neither exists (for example Electron over file://), the
680
+ // plugin still works but returns no browserUrl.
681
+ if (audioRoute) {
682
+ const webServer = serviceOf(ctx, 'webServer');
683
+ const legacy = webServer === undefined
684
+ ? serviceOf(ctx, 'httpServer')
685
+ : undefined;
686
+ const server = webServer ?? legacy;
687
+ // `getOrigin` may throw before the listener binds; then the tool output
688
+ // stays relative-only rather than advertising a guessed host.
689
+ const applyOrigin = (registrar) => {
690
+ try {
691
+ const origin = registrar.getOrigin?.();
692
+ if (origin) {
693
+ audioRoute.setOrigin(origin);
694
+ debug('audio route origin advertised by the host');
695
+ }
696
+ }
697
+ catch {
698
+ // Intentionally ignored: no origin, no absolute link.
699
+ }
700
+ };
701
+ if (server !== undefined) {
702
+ applyOrigin(server);
703
+ ctx.effect(() => audioRoute.register(server));
704
+ }
705
+ else {
706
+ ctx.inject(['webServer'], (webCtx) => {
707
+ const late = webCtx
708
+ .webServer;
709
+ if (!late)
710
+ return;
711
+ applyOrigin(late);
712
+ webCtx.effect(() => audioRoute.register(late));
713
+ });
714
+ }
715
+ }
716
+ for (const tool of createTtsTools(resolved, { audioRoute, log: debug })) {
717
+ ctx.tools.register(tool);
718
+ }
719
+ // Publish the engine for other plugins (inject: ['tts']). Best effort:
720
+ // a host without a service seam still gets the tools.
721
+ mountTtsService(ctx, resolved);
722
+ }
723
+ //# sourceMappingURL=index.js.map