@goodandready/dsh-tts 0.3.22 → 0.4.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/routes.js ADDED
@@ -0,0 +1,400 @@
1
+ import { writeJson, readBody, isTrustedSettingsRequest } from './http-util.js'
2
+ import { addStreamSubscriber, removeStreamSubscriber } from './stream-hub.js'
3
+ import { pendingKeyWrites, stripSecretsFromConfig, needsApiKey } from './keys.js'
4
+ import { PROVIDER_KEYS } from './providers.js'
5
+
6
+ /**
7
+ * Register all /dsh-tts HTTP routes.
8
+ * @param {object} ctx cordis context
9
+ * @param {object} api live bindings from apply()
10
+ */
11
+ export function registerHttpRoutes(ctx, api) {
12
+ const {
13
+ live,
14
+ modelManager,
15
+ stats,
16
+ speechCache,
17
+ cleanText,
18
+ synthesize,
19
+ credentialsView,
20
+ configResponse,
21
+ storeProviderKey,
22
+ clearProviderKey,
23
+ pending,
24
+ } = api
25
+ ctx.effect(() => ctx.webServer.register({
26
+ kind: 'exact',
27
+ path: '/dsh-tts/status',
28
+ handler: async (req, res) => {
29
+ if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
30
+ const cfg = live()
31
+ writeJson(res, 200, {
32
+ ok: true,
33
+ speakReplies: cfg.speakReplies,
34
+ enableLocalEngines: cfg.enableLocalEngines,
35
+ kokoroEnabled: cfg.kokoroEnabled,
36
+ f5Enabled: cfg.f5Enabled,
37
+ streamingEnabled: cfg.streamingEnabled,
38
+ rate: cfg.rate,
39
+ chime: cfg.chime,
40
+ bargeIn: cfg.bargeIn,
41
+ language: cfg.language,
42
+ chain: cfg.chain,
43
+ roles: cfg.roles,
44
+ providers: PROVIDER_KEYS,
45
+ })
46
+ },
47
+ }), 'dsh-tts: /status route')
48
+
49
+ ctx.effect(() => ctx.webServer.register({
50
+ kind: 'exact',
51
+ path: '/dsh-tts/stream',
52
+ handler: async (req, res) => {
53
+ if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
54
+ try {
55
+ res.writeHead(200, {
56
+ 'Content-Type': 'text/event-stream',
57
+ 'Cache-Control': 'no-cache, no-transform',
58
+ 'Connection': 'keep-alive',
59
+ })
60
+ res.write(': connected\n\n')
61
+ addStreamSubscriber(res)
62
+ req.on('close', () => removeStreamSubscriber(res))
63
+ } catch {
64
+ removeStreamSubscriber(res)
65
+ }
66
+ },
67
+ }), 'dsh-tts: /stream route')
68
+
69
+
70
+ ctx.effect(() => ctx.webServer.register({
71
+ kind: 'exact',
72
+ path: '/dsh-tts/models/status',
73
+ handler: async (req, res) => {
74
+ if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
75
+ try {
76
+ const list = await modelManager.listStatus()
77
+ writeJson(res, 200, { ok: true, models: list })
78
+ } catch (err) {
79
+ writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
80
+ }
81
+ },
82
+ }), 'dsh-tts: /models/status route')
83
+
84
+ ctx.effect(() => ctx.webServer.register({
85
+ kind: 'exact',
86
+ path: '/dsh-tts/models/install',
87
+ handler: async (req, res) => {
88
+ if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
89
+ if (!isTrustedSettingsRequest(req)) {
90
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
91
+ return
92
+ }
93
+ let body = {}
94
+ try {
95
+ const raw = await readBody(req, 16 * 1024)
96
+ body = JSON.parse(raw.toString('utf8') || '{}')
97
+ } catch (e) {
98
+ writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
99
+ return
100
+ }
101
+ const engine = String(body.engine || '').trim()
102
+ if (!engine) {
103
+ writeJson(res, 400, { ok: false, error: { code: 'param', message: 'missing engine parameter' } })
104
+ return
105
+ }
106
+ try {
107
+ // Fire installation in background or await initial start
108
+ modelManager.installModel(engine).catch(() => {})
109
+ const current = await modelManager.getStatus(engine)
110
+ writeJson(res, 200, { ok: true, model: current })
111
+ } catch (err) {
112
+ writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
113
+ }
114
+ },
115
+ }), 'dsh-tts: /models/install route')
116
+
117
+ ctx.effect(() => ctx.webServer.register({
118
+ kind: 'exact',
119
+ path: '/dsh-tts/models/delete',
120
+ handler: async (req, res) => {
121
+ if (req.method !== 'DELETE' && req.method !== 'POST') {
122
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'DELETE or POST only' } })
123
+ return
124
+ }
125
+ if (!isTrustedSettingsRequest(req)) {
126
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
127
+ return
128
+ }
129
+ let body = {}
130
+ try {
131
+ const raw = await readBody(req, 16 * 1024)
132
+ body = JSON.parse(raw.toString('utf8') || '{}')
133
+ } catch { body = {} }
134
+ const engine = String(body.engine || '').trim()
135
+ if (!engine) {
136
+ writeJson(res, 400, { ok: false, error: { code: 'param', message: 'missing engine parameter' } })
137
+ return
138
+ }
139
+ try {
140
+ const result = await modelManager.deleteModel(engine)
141
+ writeJson(res, 200, { ok: true, model: result })
142
+ } catch (err) {
143
+ writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
144
+ }
145
+ },
146
+ }), 'dsh-tts: /models/delete route')
147
+
148
+ ctx.effect(() => ctx.webServer.register({
149
+ kind: 'exact',
150
+ path: '/dsh-tts/integrations',
151
+ handler: async (req, res) => {
152
+ if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
153
+ let voiceInstalled = false
154
+ let messengerInstalled = false
155
+
156
+ try {
157
+ if (typeof ctx.webServer?.hasRoute === 'function') {
158
+ voiceInstalled = ctx.webServer.hasRoute('/dsh-voice/status')
159
+ messengerInstalled = ctx.webServer.hasRoute('/dsh-messenger-gateway/status')
160
+ }
161
+ } catch {}
162
+
163
+ // Runtime routes are the only reliable signal. Do not probe ../ siblings
164
+ // or node_modules layouts — they lie outside a normal npm install.
165
+
166
+ writeJson(res, 200, {
167
+ ok: true,
168
+ voice: {
169
+ installed: voiceInstalled,
170
+ package: '@goodandready/dsh-voice',
171
+ hint: 'dsh plugin --profile web add @goodandready/dsh-voice',
172
+ },
173
+ messenger: {
174
+ installed: messengerInstalled,
175
+ package: '@goodandready/dsh-messenger-gateway',
176
+ hint: 'dsh plugin --profile web add @goodandready/dsh-messenger-gateway',
177
+ },
178
+ })
179
+ },
180
+ }), 'dsh-tts: /integrations route')
181
+
182
+ ctx.effect(() => ctx.webServer.register({
183
+ kind: 'exact',
184
+ path: '/dsh-tts/cache',
185
+ handler: async (req, res) => {
186
+ if (req.method !== 'DELETE') {
187
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'DELETE only' } })
188
+ return
189
+ }
190
+ if (!isTrustedSettingsRequest(req)) {
191
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
192
+ return
193
+ }
194
+ const removed = await speechCache.clear()
195
+ writeJson(res, 200, { ok: true, removed })
196
+ },
197
+ }), 'dsh-tts: /cache route')
198
+
199
+ ctx.effect(() => ctx.webServer.register({
200
+ kind: 'exact',
201
+ path: '/dsh-tts/preview',
202
+ handler: async (req, res) => {
203
+ if (req.method !== 'POST') {
204
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
205
+ return
206
+ }
207
+ if (!isTrustedSettingsRequest(req)) {
208
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
209
+ return
210
+ }
211
+ let body = {}
212
+ try {
213
+ const raw = await readBody(req, 64 * 1024)
214
+ body = JSON.parse(raw.toString('utf8') || '{}')
215
+ } catch (e) {
216
+ writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
217
+ return
218
+ }
219
+ const cfg = live()
220
+ const phrase = String(cfg.language || '').toLowerCase().startsWith('ru') ? 'Проверка голоса.' : 'Voice check.'
221
+ try {
222
+ const probe = Object.assign({}, cfg, {
223
+ chain: [{ provider: body.provider || '', model: body.model || '', voice: body.voice || '' }],
224
+ })
225
+ const out = await synthesize(String(body.text || phrase), probe, null, 'reply')
226
+ writeJson(res, 200, { ok: true, mime: out.mime, audioBase64: Buffer.from(out.audio).toString('base64') })
227
+ } catch (e) {
228
+ writeJson(res, 502, { ok: false, error: { code: 'tts', message: String(e && e.message || e) } })
229
+ }
230
+ },
231
+ }), 'dsh-tts: /preview route')
232
+
233
+ ctx.effect(() => ctx.webServer.register({
234
+ kind: 'exact',
235
+ path: '/dsh-tts/stats',
236
+ handler: async (req, res) => {
237
+ if (req.method === 'GET') {
238
+ writeJson(res, 200, { ok: true, total: stats.total, cacheHits: stats.cacheHits, errors: stats.errors, providers: stats.providers })
239
+ return
240
+ }
241
+ if (req.method !== 'DELETE') {
242
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or DELETE' } })
243
+ return
244
+ }
245
+ if (!isTrustedSettingsRequest(req)) {
246
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
247
+ return
248
+ }
249
+ stats.total = 0
250
+ stats.cacheHits = 0
251
+ stats.errors = 0
252
+ stats.providers = {}
253
+ writeJson(res, 200, { ok: true })
254
+ },
255
+ }), 'dsh-tts: /stats route')
256
+
257
+ ctx.effect(() => ctx.webServer.register({
258
+ kind: 'exact',
259
+ path: '/dsh-tts/config',
260
+ handler: async (req, res) => {
261
+ if (req.method === 'GET') {
262
+ writeJson(res, 200, await configResponse())
263
+ return
264
+ }
265
+ if (req.method !== 'PUT' && req.method !== 'POST') {
266
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or PUT' } })
267
+ return
268
+ }
269
+ if (!isTrustedSettingsRequest(req)) {
270
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-tts: settings writes are same-origin only' } })
271
+ return
272
+ }
273
+ if (!settingsApi) {
274
+ writeJson(res, 503, { ok: false, error: { code: 'settings', message: 'settings not ready' } })
275
+ return
276
+ }
277
+ let raw
278
+ try { raw = await readBody(req, 256 * 1024) } catch (e) {
279
+ writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
280
+ return
281
+ }
282
+ let payload
283
+ try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch {
284
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
285
+ return
286
+ }
287
+ if (payload && typeof payload.config === 'object') {
288
+ payload = { ...payload.config, keys: payload.keys }
289
+ }
290
+ const keys = pendingKeyWrites(payload && payload.keys)
291
+ const stripped = stripSecretsFromConfig(payload)
292
+ let parsed
293
+ try { parsed = Config(stripped) } catch (e) {
294
+ writeJson(res, 400, { ok: false, error: { code: 'schema', message: String(e && e.message || e) } })
295
+ return
296
+ }
297
+ try {
298
+ for (const item of keys) {
299
+ await storeProviderKey(item.provider, item.value)
300
+ }
301
+ await settingsApi.replace(parsed)
302
+ writeJson(res, 200, await configResponse())
303
+ } catch (e) {
304
+ writeJson(res, 500, { ok: false, error: { code: 'save', message: String(e && e.message || e) } })
305
+ }
306
+ },
307
+ }), 'dsh-tts: /config route')
308
+
309
+ ctx.effect(() => ctx.webServer.register({
310
+ kind: 'exact',
311
+ path: '/dsh-tts/credential',
312
+ handler: async (req, res) => {
313
+ if (req.method !== 'PUT' && req.method !== 'DELETE') {
314
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'PUT or DELETE only' } })
315
+ return
316
+ }
317
+ if (!isTrustedSettingsRequest(req)) {
318
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-tts: keys are same-origin only' } })
319
+ return
320
+ }
321
+ let payload = {}
322
+ if (req.method === 'PUT' || (req.headers['content-length'] && Number(req.headers['content-length']) > 0)) {
323
+ let raw
324
+ try { raw = await readBody(req, 16 * 1024) } catch (e) {
325
+ writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
326
+ return
327
+ }
328
+ try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch {
329
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
330
+ return
331
+ }
332
+ }
333
+ const provider = typeof payload.provider === 'string' ? payload.provider.trim() : ''
334
+ if (!needsApiKey(provider)) {
335
+ writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown cloud provider' } })
336
+ return
337
+ }
338
+ try {
339
+ if (req.method === 'DELETE') {
340
+ const ref = await clearProviderKey(provider)
341
+ writeJson(res, 200, { ok: true, ref, credentials: await credentialsView() })
342
+ return
343
+ }
344
+ const ref = await storeProviderKey(provider, payload.value)
345
+ writeJson(res, 200, { ok: true, ref, credentials: await credentialsView() })
346
+ } catch (e) {
347
+ writeJson(res, 400, { ok: false, error: { code: 'credential', message: String(e && e.message || e) } })
348
+ }
349
+ },
350
+ }), 'dsh-tts: /credential route')
351
+
352
+ ctx.effect(() => ctx.webServer.register({
353
+ kind: 'exact',
354
+ path: '/dsh-tts/pending',
355
+ handler: async (req, res) => {
356
+ if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
357
+ const url = new URL(req.url || '/', 'http://127.0.0.1')
358
+ const after = url.searchParams.get('after') || ''
359
+ let items = pending.slice()
360
+ if (after) {
361
+ const idx = items.findIndex((x) => x.id === after)
362
+ items = idx >= 0 ? items.slice(idx + 1) : items
363
+ }
364
+ writeJson(res, 200, { ok: true, items })
365
+ },
366
+ }), 'dsh-tts: /pending route')
367
+
368
+ ctx.effect(() => ctx.webServer.register({
369
+ kind: 'exact',
370
+ path: '/dsh-tts/speak',
371
+ handler: async (req, res) => {
372
+ if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
373
+ let raw
374
+ try { raw = await readBody(req, 256 * 1024) } catch (e) {
375
+ writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
376
+ }
377
+ let payload
378
+ try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
379
+ const text = cleanText(payload.text, live())
380
+ if (!text) { writeJson(res, 400, { ok: false, error: { code: 'no-text', message: 'no text' } }); return }
381
+ const cfg = live()
382
+ const controller = new AbortController()
383
+ const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
384
+ try {
385
+ const out = await synthesize(text, cfg, controller.signal)
386
+ writeJson(res, 200, {
387
+ ok: true,
388
+ provider: out.provider,
389
+ mime: out.mime,
390
+ audioBase64: Buffer.from(out.audio).toString('base64'),
391
+ tookMs: out.tookMs,
392
+ })
393
+ } catch (e) {
394
+ writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
395
+ } finally {
396
+ clearTimeout(timer)
397
+ }
398
+ },
399
+ }), 'dsh-tts: /speak route')
400
+ }
@@ -0,0 +1,31 @@
1
+ // SSE subscriber hub for /dsh-tts/stream.
2
+
3
+ const subscribers = new Set()
4
+
5
+ export function addStreamSubscriber(res) {
6
+ subscribers.add(res)
7
+ }
8
+
9
+ export function removeStreamSubscriber(res) {
10
+ subscribers.delete(res)
11
+ }
12
+
13
+ export function broadcastStream(type, item) {
14
+ const payload = `event: ${type}\ndata: ${JSON.stringify(item)}\n\n`
15
+ for (const res of subscribers) {
16
+ try {
17
+ res.write(payload)
18
+ } catch {
19
+ subscribers.delete(res)
20
+ }
21
+ }
22
+ }
23
+
24
+ export function clearStreamSubscribers() {
25
+ for (const res of subscribers) {
26
+ try {
27
+ res.end()
28
+ } catch { /* already closed */ }
29
+ }
30
+ subscribers.clear()
31
+ }
package/lib/text.js CHANGED
@@ -11,8 +11,8 @@ export function blocksToText(content) {
11
11
  return parts.join('\n')
12
12
  }
13
13
 
14
- // Короткие пометки вместо выброшенной разметки. Формулировки выбираются по
15
- // языку озвучки из настроек; подстановки {n} заполняются числом строк.
14
+ // Short spoken notices replace dropped markup. Wording follows
15
+ // the configured speech language; {n} is filled with a line/row count.
16
16
  export const SPEECH_PHRASES = {
17
17
  en: { codeBlock: 'code block, {n} lines', table: 'table, {n} rows', summaryIntro: 'Summary of the reply.' },
18
18
  ru: { codeBlock: 'блок кода, {n} строк', table: 'таблица, {n} строк', summaryIntro: 'Пересказ ответа.' },
@@ -23,8 +23,8 @@ export function speechPhrases(language) {
23
23
  return lang.startsWith('ru') ? SPEECH_PHRASES.ru : SPEECH_PHRASES.en
24
24
  }
25
25
 
26
- // Фильтры наррации: вырезания поверх общей чистки, до словаря произношения.
27
- // Порядок: своя регулярка -> блоки *действий* -> режим «только цитаты».
26
+ // Narration filters run on top of general scrubbing, before pronunciation.
27
+ // Order: custom regex -> *action* blocks -> quotes-only mode.
28
28
  export function applyNarrationFilters(text, cfg) {
29
29
  let out = String(text || '')
30
30
  if (cfg && cfg.removeRegex) {
@@ -40,7 +40,7 @@ export function applyNarrationFilters(text, cfg) {
40
40
  return out
41
41
  }
42
42
 
43
- // Грубая эвристика языка куска: где букв больше, тот язык и подсказываем.
43
+ // Rough language heuristic: whichever script has more letters wins.
44
44
  export function detectLang(text) {
45
45
  const s = String(text || '')
46
46
  const cyr = (s.match(/[\u0400-\u04FF]/g) || []).length
@@ -48,10 +48,10 @@ export function detectLang(text) {
48
48
  return cyr > lat ? 'ru' : 'en'
49
49
  }
50
50
 
51
- // Словарь произношения: правила применяются сверху вниз, каждое по одному
52
- // разу ко всему тексту. Левая часть — обычный текст; запись вида /…/флаги
53
- // трактуется как регулярное выражение для тех, кто умеет. whole=true требует
54
- // совпадения целого слова (границы по буквам/цифрам Unicode).
51
+ // Pronunciation rules apply top-down, once each over the whole text.
52
+ // Left side is plain text; /…/flags form is treated as a regular expression.
53
+ // whole=true requires a full word match (Unicode letter/digit boundaries).
54
+
55
55
  function escapeRe(s) {
56
56
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
57
57
  }
@@ -114,8 +114,8 @@ export function stripForSpeech(raw, maxChars, opts) {
114
114
  const skipCode = options.skipCode !== false
115
115
  const phrases = options.phrases || SPEECH_PHRASES.en
116
116
  let text = String(raw || '')
117
- // Чистка обязана идти до нарезки на предложения, иначе половина блока
118
- // уедет в первый кусок и будет озвучена.
117
+ // Scrubbing must run before sentence splitting or half a fenced block ends
118
+ // up in the first chunk and is spoken.
119
119
  if (skipCode) {
120
120
  text = text.replace(/```[\s\S]*?```/g, (block) => {
121
121
  const inner = block.replace(/^```[^\n]*\n?/, '').replace(/\n?```\s*$/, '')
@@ -124,8 +124,8 @@ export function stripForSpeech(raw, maxChars, opts) {
124
124
  })
125
125
  text = text.replace(/`[^`\n]+`/g, ' ')
126
126
  }
127
- // Таблицы Markdown: подряд идущие строки, начинающиеся с |; разделительная
128
- // шапка |---| в счёт строк не идёт.
127
+ // Markdown tables: consecutive lines starting with |; the |---| separator
128
+ // row is not counted as a data row.
129
129
  text = text.replace(/(?:^[ \t]*\|.*\|[ \t]*$\n?)+/gm, (run) => {
130
130
  const rows = run.trim().split('\n')
131
131
  .filter((line) => !/^\s*\|[\s:|-]+\|\s*$/.test(line)).length
@@ -136,7 +136,7 @@ export function stripForSpeech(raw, maxChars, opts) {
136
136
  text = text.replace(/[#*_>]+/g, ' ')
137
137
  text = text.replace(/MEDIA:[^\s]+/g, ' ')
138
138
  text = text.replace(/\s+/g, ' ').trim()
139
- // maxChars === 0 отключает обрезку; не задан — дефолтные 4000.
139
+ // maxChars === 0 disables truncation; unset means the 4000 default.
140
140
  const requested = Number(maxChars)
141
141
  if (requested > 0 && text.length > requested) text = text.slice(0, requested)
142
142
  return text
@@ -147,6 +147,32 @@ export function assistantText(message) {
147
147
  return blocksToText(message.content)
148
148
  }
149
149
 
150
+ // Protect common abbreviations from false sentence splits
151
+ const PROTECTED_ABBREVIATIONS = [
152
+ // Russian abbreviations
153
+ 'т.е.', 'т.д.', 'т.п.', 'т.к.', 'руб.', 'коп.', 'ул.', 'пер.', 'пр.', 'просп.',
154
+ 'д.', 'корп.', 'стр.', 'кв.', 'г.', 'гг.', 'в.', 'вв.', 'см.', 'ср.', 'напр.',
155
+ 'рис.', 'табл.', 'им.', 'акад.', 'проф.', 'доц.', 'млн.', 'млрд.', 'тыс.',
156
+ // English abbreviations
157
+ 'e.g.', 'i.e.', 'vs.', 'etc.', 'dr.', 'mr.', 'mrs.', 'ms.', 'prof.',
158
+ 'approx.', 'dept.', 'est.', 'min.', 'max.', 'no.', 'vol.', 'p.m.', 'a.m.'
159
+ ]
160
+
161
+ export function protectAbbreviations(text) {
162
+ let res = String(text || '')
163
+ for (const abbr of PROTECTED_ABBREVIATIONS) {
164
+ const escaped = abbr.replace(/\./g, '\\.')
165
+ const rx = new RegExp('(?<=^|[\\s"«(])' + escaped + '(?=[\\s"»)\\]]|$)', 'gi')
166
+ res = res.replace(rx, (m) => m.replace(/\./g, '\u2024'))
167
+ }
168
+ res = res.replace(/(?<=[vV]?\d+)\.(?=\d+)/g, '\u2024')
169
+ return res
170
+ }
171
+
172
+ export function restoreAbbreviations(text) {
173
+ return String(text || '').replace(/\u2024/g, '.')
174
+ }
175
+
150
176
  /**
151
177
  * Режет текст на куски по границам предложений.
152
178
  *
@@ -161,18 +187,19 @@ export function assistantText(message) {
161
187
  export function splitSentences(text, maxChars) {
162
188
  const limit = Number(maxChars) > 0 ? Number(maxChars) : 320
163
189
  const min = Math.min(60, Math.floor(limit / 3))
164
- const source = String(text || '').trim()
165
- if (!source) return []
190
+ const raw = String(text || '').trim()
191
+ if (!raw) return []
166
192
 
193
+ const source = protectAbbreviations(raw)
167
194
  const pieces = []
168
195
  let current = ''
169
- // Границей считаем точку, вопрос, восклицание, многоточие и перевод строки.
196
+ // Boundaries are period, question, exclamation, ellipsis, and newline.
170
197
  for (const part of source.split(/(?<=[.!?…])\s+|\n+/)) {
171
198
  const chunk = String(part || '').trim()
172
199
  if (!chunk) continue
173
200
  if (!current) { current = chunk } else if (current.length < min) { current += ' ' + chunk }
174
201
  else { pieces.push(current); current = chunk }
175
- // Кусок длиннее предела режем по словам: провайдеры не любят простыни.
202
+ // Oversized pieces are split on words: providers choke on walls of text.
176
203
  while (current.length > limit) {
177
204
  let cut = current.lastIndexOf(' ', limit)
178
205
  if (cut < min) cut = limit
@@ -181,5 +208,5 @@ export function splitSentences(text, maxChars) {
181
208
  }
182
209
  }
183
210
  if (current) pieces.push(current)
184
- return pieces.filter(Boolean)
211
+ return pieces.filter(Boolean).map(restoreAbbreviations)
185
212
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-tts",
3
- "version": "0.3.22",
3
+ "version": "0.4.0",
4
4
  "description": "Text-to-speech for DeepSeek Harness: speak agent replies in the Web UI with a provider fallback chain (OpenAI, ElevenLabs, Google, Azure, Groq, Deepgram, OpenRouter, Edge, Piper, eSpeak).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -13,9 +13,12 @@
13
13
  },
14
14
  "files": [
15
15
  "lib/",
16
+ "scripts/",
16
17
  "cordis.patch.yml",
17
18
  "README.md",
18
- "LICENSE"
19
+ "LICENSE",
20
+ "README.ru.md",
21
+ "README.zh.md"
19
22
  ],
20
23
  "keywords": [
21
24
  "dsh",
@@ -63,4 +66,4 @@
63
66
  "publishConfig": {
64
67
  "access": "public"
65
68
  }
66
- }
69
+ }
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env python3
2
+ """F5-TTS control stub.
3
+
4
+ Honest status only: ping answers when this script can run. It does not
5
+ synthesize audio. Real F5 inference is out of scope for this package until a
6
+ supported external runtime is documented and wired.
7
+ """
8
+ import sys
9
+ import json
10
+
11
+
12
+ def main():
13
+ if len(sys.argv) > 1 and sys.argv[1] == '--ping':
14
+ print(json.dumps({"ok": True, "status": "ready", "synthesize": False}))
15
+ sys.exit(0)
16
+
17
+ for line in sys.stdin:
18
+ line = line.strip()
19
+ if not line:
20
+ continue
21
+ try:
22
+ req = json.loads(line)
23
+ cmd = req.get("cmd")
24
+ if cmd == "ping":
25
+ print(json.dumps({"ok": True, "status": "pong", "synthesize": False}))
26
+ elif cmd == "synthesize":
27
+ print(json.dumps({
28
+ "ok": False,
29
+ "error": "F5 synthesis is not bundled; use Edge/Piper/eSpeak offline providers",
30
+ }))
31
+ else:
32
+ print(json.dumps({"ok": False, "error": f"unknown cmd: {cmd}"}))
33
+ sys.stdout.flush()
34
+ except Exception as e:
35
+ print(json.dumps({"ok": False, "error": str(e)}))
36
+ sys.stdout.flush()
37
+
38
+
39
+ if __name__ == "__main__":
40
+ main()