@goodandready/dsh-tts 0.1.1 → 0.2.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/client.js CHANGED
@@ -53,22 +53,141 @@ window.__ModuleLoader__.load({
53
53
  document.head.appendChild(tag)
54
54
  }
55
55
 
56
- const player = { audio: null, after: '', enabled: false }
56
+ // Проигрыватель.
57
+ //
58
+ // Раньше каждый пришедший кусок немедленно вытеснял предыдущий: при чтении
59
+ // ответа целиком это было незаметно, а при чтении по ходу слышался бы
60
+ // только последний. Теперь это очередь: следующее начинается, когда
61
+ // закончилось предыдущее.
62
+ const player = {
63
+ audio: null,
64
+ after: '',
65
+ enabled: false,
66
+ rate: 1,
67
+ chime: 'ding',
68
+ queue: [],
69
+ busy: false,
70
+ paused: false,
71
+ listeners: new Set(),
72
+ }
73
+
74
+ function playerChanged() {
75
+ for (const listener of [...player.listeners]) {
76
+ try { listener() } catch (listenerFailure) { /* чужой слушатель не наша забота */ }
77
+ }
78
+ }
79
+
80
+ function usePlayer() {
81
+ const [, force] = React.useReducer((n) => n + 1, 0)
82
+ React.useEffect(() => {
83
+ player.listeners.add(force)
84
+ return () => { player.listeners.delete(force) }
85
+ }, [])
86
+ return player
87
+ }
88
+
89
+ // Короткий сигнал рисуем сами: файл не нужен, значит нечего качать,
90
+ // хранить и настраивать.
91
+ function playChime(kind) {
92
+ return new Promise((resolve) => {
93
+ try {
94
+ const AC = window.AudioContext || window.webkitAudioContext
95
+ if (!AC) { resolve(); return }
96
+ const audioCtx = new AC()
97
+ const gain = audioCtx.createGain()
98
+ gain.connect(audioCtx.destination)
99
+ const tones = kind === 'beep' ? [880, 880] : [660, 990]
100
+ let at = audioCtx.currentTime
101
+ for (const freq of tones) {
102
+ const osc = audioCtx.createOscillator()
103
+ osc.frequency.value = freq
104
+ osc.connect(gain)
105
+ gain.gain.setValueAtTime(0.0001, at)
106
+ gain.gain.exponentialRampToValueAtTime(0.25, at + 0.02)
107
+ gain.gain.exponentialRampToValueAtTime(0.0001, at + 0.18)
108
+ osc.start(at)
109
+ osc.stop(at + 0.2)
110
+ at += 0.22
111
+ }
112
+ setTimeout(() => { try { audioCtx.close() } catch (already) { /* закрыт */ } resolve() }, 600)
113
+ } catch (noAudio) { resolve() }
114
+ })
115
+ }
116
+
117
+ // Провайдер не смог — читаем голосом браузера. Хуже по звучанию, но
118
+ // молчание хуже вдвойне: человек не узнает, что ответ готов.
119
+ function speakInBrowser(text) {
120
+ return new Promise((resolve) => {
121
+ try {
122
+ if (!window.speechSynthesis || !text) { resolve(); return }
123
+ const utterance = new SpeechSynthesisUtterance(String(text))
124
+ utterance.rate = Math.max(0.5, Math.min(2, player.rate || 1))
125
+ utterance.onend = () => resolve()
126
+ utterance.onerror = () => resolve()
127
+ window.speechSynthesis.speak(utterance)
128
+ } catch (noSpeech) { resolve() }
129
+ })
130
+ }
131
+
132
+ function playAudio(item) {
133
+ return new Promise((resolve) => {
134
+ try {
135
+ const bin = atob(item.audioBase64)
136
+ const bytes = new Uint8Array(bin.length)
137
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
138
+ const blob = new Blob([bytes], { type: item.mime || 'audio/mpeg' })
139
+ const url = URL.createObjectURL(blob)
140
+ const audio = new Audio(url)
141
+ audio.playbackRate = Math.max(0.5, Math.min(2, player.rate || 1))
142
+ player.audio = audio
143
+ playerChanged()
144
+ const done = () => { URL.revokeObjectURL(url); player.audio = null; playerChanged(); resolve() }
145
+ audio.onended = done
146
+ audio.onerror = done
147
+ audio.play().catch(() => {
148
+ // Браузер не даёт играть без действия человека — не считаем это
149
+ // поломкой, просто ничего не звучит.
150
+ done()
151
+ })
152
+ } catch (cannotDecode) { resolve() }
153
+ })
154
+ }
57
155
 
58
- function playItem(item) {
59
- if (!item || item.error || !item.audioBase64) return
156
+ async function drainQueue() {
157
+ if (player.busy) return
158
+ player.busy = true
159
+ playerChanged()
60
160
  try {
61
- if (player.audio) { try { player.audio.pause() } catch (e) { /* ignore */ } }
62
- const bin = atob(item.audioBase64)
63
- const bytes = new Uint8Array(bin.length)
64
- for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
65
- const blob = new Blob([bytes], { type: item.mime || 'audio/mpeg' })
66
- const url = URL.createObjectURL(blob)
67
- const audio = new Audio(url)
68
- player.audio = audio
69
- audio.onended = () => { URL.revokeObjectURL(url) }
70
- audio.play().catch(() => { URL.revokeObjectURL(url) })
71
- } catch (e) { /* autoplay policy or decode */ }
161
+ while (player.queue.length) {
162
+ if (player.paused) break
163
+ const item = player.queue.shift()
164
+ if (!item || item.kind === 'reserved') continue
165
+ if (item.kind === 'chime') { await playChime(item.chime || player.chime); continue }
166
+ if (item.audioBase64) await playAudio(item)
167
+ else if (item.error && item.text) await speakInBrowser(item.text)
168
+ }
169
+ } finally {
170
+ player.busy = false
171
+ playerChanged()
172
+ }
173
+ }
174
+
175
+ function stopPlayback() {
176
+ player.queue.length = 0
177
+ player.paused = false
178
+ if (player.audio) { try { player.audio.pause() } catch (already) { /* стоит */ } }
179
+ player.audio = null
180
+ try { if (window.speechSynthesis) window.speechSynthesis.cancel() } catch (noSpeech) { /* нет синтеза */ }
181
+ playerChanged()
182
+ }
183
+
184
+ function togglePause() {
185
+ player.paused = !player.paused
186
+ if (player.audio) {
187
+ try { player.paused ? player.audio.pause() : player.audio.play() } catch (already) { /* всё равно */ }
188
+ }
189
+ if (!player.paused) drainQueue()
190
+ playerChanged()
72
191
  }
73
192
 
74
193
  async function pollPending() {
@@ -77,17 +196,22 @@ window.__ModuleLoader__.load({
77
196
  if (!st.ok) return
78
197
  const meta = await st.json()
79
198
  player.enabled = !!(meta && meta.speakReplies)
199
+ if (meta && typeof meta.rate === 'number') player.rate = meta.rate
200
+ if (meta && meta.chime) player.chime = meta.chime
80
201
  if (!player.enabled) return
81
202
  const res = await fetch('/dsh-tts/pending?after=' + encodeURIComponent(player.after || ''), { cache: 'no-store' })
82
203
  if (!res.ok) return
83
204
  const data = await res.json()
84
205
  const items = data && Array.isArray(data.items) ? data.items : []
85
- for (let i = 0; i < items.length; i++) {
86
- const item = items[i]
206
+ for (const item of items) {
207
+ // Место в очереди занято, но синтез ещё идёт — подождём следующего
208
+ // опроса, иначе порядок фраз рассыплется.
209
+ if (item.kind === 'reserved') break
87
210
  player.after = item.id
88
- playItem(item)
211
+ player.queue.push(item)
89
212
  }
90
- } catch (e) { /* host restarting */ }
213
+ if (player.queue.length) drainQueue()
214
+ } catch (hostRestarting) { /* хост перезапускается */ }
91
215
  }
92
216
 
93
217
  function credOf(map, provider) {
@@ -243,6 +367,21 @@ window.__ModuleLoader__.load({
243
367
  commitKey(provider, raw).catch((e) => setErr(String(e && e.message ? e.message : e)))
244
368
  }
245
369
 
370
+ const boolField = (key, label, hint) => React.createElement('label', { className: 'dts-field' }, label,
371
+ React.createElement('input', {
372
+ type: 'checkbox', checked: !!(draft && draft[key]), disabled: !writable,
373
+ onChange: (e) => setTop(key, e.target.checked),
374
+ }),
375
+ React.createElement('span', { className: 'dts-sub' }, hint))
376
+
377
+ const numberField = (key, label, hint, step) => React.createElement('label', { className: 'dts-field' }, label,
378
+ React.createElement('input', {
379
+ type: 'number', step: step || 1,
380
+ value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
381
+ onChange: (e) => setTop(key, Number(e.target.value)),
382
+ }),
383
+ React.createElement('span', { className: 'dts-sub' }, hint))
384
+
246
385
  const textField = (key, label, hint) => React.createElement('label', { className: 'dts-field' }, label,
247
386
  React.createElement('input', {
248
387
  value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
@@ -278,6 +417,14 @@ window.__ModuleLoader__.load({
278
417
  ),
279
418
  React.createElement('div', { className: 'dts-block' },
280
419
  React.createElement('div', { className: 'dts-h' }, 'General'),
420
+ boolField('speakAsItGoes', 'Speak as it goes',
421
+ 'Read each reply the moment it lands, sentence by sentence, instead of waiting for the whole turn.'),
422
+ numberField('rate', 'Playback speed', '0.5 to 2. Synthesis is untouched; the browser plays faster or slower.', 0.1),
423
+ boolField('announceApproval', 'Announce approvals',
424
+ 'Say it out loud and play a chime when the agent stops for an approval or asks a question.'),
425
+ textField('approvalText', 'Approval wording', 'Said when an approval is asked for; the tool name is appended.'),
426
+ textField('questionText', 'Question wording', 'Said when the agent asks a question. Empty disables it.'),
427
+ textField('chime', 'Chime', 'ding, beep or none.'),
281
428
  textField('language', 'Language', 'Hint for voices that need a locale (eSpeak, Azure SSML).'),
282
429
  textField('piperModel', 'Piper model path', 'Absolute path to an ONNX model. Leave empty to skip Piper.'),
283
430
  textField('piperBin', 'Piper binary', 'Looked up in PATH unless absolute.'),
@@ -293,6 +440,34 @@ window.__ModuleLoader__.load({
293
440
  )
294
441
  }
295
442
 
443
+ // Кнопка в строке ввода: видно, что сейчас читается, и можно остановить.
444
+ function SpeakerControl() {
445
+ const p = usePlayer()
446
+ const active = !!p.audio || p.queue.length > 0 || p.busy
447
+ if (!active) return null
448
+ return React.createElement('div', { style: { display: 'inline-flex', gap: '4px', alignItems: 'center' } },
449
+ React.createElement('button', {
450
+ type: 'button',
451
+ className: 'dts-link',
452
+ title: p.paused ? 'Продолжить чтение' : 'Пауза',
453
+ onClick: togglePause,
454
+ }, p.paused ? '▶' : '❚❚'),
455
+ React.createElement('button', {
456
+ type: 'button',
457
+ className: 'dts-link',
458
+ title: 'Прекратить чтение',
459
+ onClick: stopPlayback,
460
+ }, '■'),
461
+ )
462
+ }
463
+
464
+ function registerSpeaker(ctx) {
465
+ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register(
466
+ { name: 'conversation.input.dock', id: '@goodandready/dsh-tts', order: 5, label: () => 'Чтение' },
467
+ SpeakerControl,
468
+ ))
469
+ }
470
+
296
471
  function registerSettings(ctx) {
297
472
  ctx.slots.inject('settings.section', () => ctx.slots.register(
298
473
  { name: 'settings.section', id: '@goodandready/dsh-tts', order: 32, label: () => 'Speech', inject: () => ({ ctx: ctx }) },
@@ -303,6 +478,7 @@ window.__ModuleLoader__.load({
303
478
  exports.inject = ['slots']
304
479
  exports.apply = function apply(ctx) {
305
480
  registerSettings(ctx)
481
+ registerSpeaker(ctx)
306
482
  setInterval(pollPending, 1000)
307
483
  }
308
484
  return module.exports
package/lib/index.js CHANGED
@@ -3,7 +3,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
3
3
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
4
4
  import { runChain } from './chain.js'
5
5
  import { makeProviders, PROVIDER_KEYS, DEFAULT_MODELS, DEFAULT_VOICES } from './providers.js'
6
- import { assistantText, stripForSpeech } from './text.js'
6
+ import { assistantText, stripForSpeech, splitSentences } from './text.js'
7
7
  import {
8
8
  CLOUD_PROVIDERS,
9
9
  assertCredentialRef,
@@ -32,6 +32,36 @@ export const Config = z.object({
32
32
  speakReplies: z.boolean().default(false)
33
33
  .description('When on, speak each finished agent reply in the Web UI.'),
34
34
  language: z.string().default('ru'),
35
+ speakAsItGoes: z
36
+ .boolean()
37
+ .description('Speak each reply as it lands instead of waiting for the whole turn to finish. '
38
+ + 'Long answers start sounding almost at once because they are synthesized sentence by sentence.')
39
+ .default(true),
40
+ sentenceChars: z
41
+ .number()
42
+ .description('Upper bound of one spoken piece when speaking as it goes.')
43
+ .default(320),
44
+ rate: z
45
+ .number()
46
+ .description('Playback speed, 0.5 to 2. Synthesis is untouched; the browser plays faster or slower.')
47
+ .default(1),
48
+ announceApproval: z
49
+ .boolean()
50
+ .description('Say out loud when the agent stops and waits for an approval, and play a short chime. '
51
+ + 'Useful when you walk away from a long run.')
52
+ .default(true),
53
+ approvalText: z
54
+ .string()
55
+ .description('What to say when an approval is asked for. The tool name is appended.')
56
+ .default('Требуется подтверждение'),
57
+ questionText: z
58
+ .string()
59
+ .description('What to say when the agent asks a question. Empty disables it.')
60
+ .default('Агент задал вопрос'),
61
+ chime: z
62
+ .string()
63
+ .description('Short sound before an announcement: ding, beep or none.')
64
+ .default('ding'),
35
65
  maxChars: z.number().default(4000)
36
66
  .description('Longer replies are truncated before synthesis.'),
37
67
  chain: z.array(ChainEntry)
@@ -48,6 +78,9 @@ export const Config = z.object({
48
78
  azureKeyEnv: z.string().default('AZURE_SPEECH_KEY'),
49
79
  azureRegion: z.string().default('')
50
80
  .description('Azure Speech region, e.g. eastus. Required for the azure provider.'),
81
+ siliconflowKeyEnv: z.string().description('Credential holding the SiliconFlow key.').default('SILICONFLOW_API_KEY'),
82
+ deepinfraKeyEnv: z.string().description('Credential holding the DeepInfra key.').default('DEEPINFRA_API_KEY'),
83
+ fireworksKeyEnv: z.string().description('Credential holding the Fireworks key.').default('FIREWORKS_API_KEY'),
51
84
  groqKeyEnv: z.string().default('GROQ_API_KEY'),
52
85
  deepgramKeyEnv: z.string().default('DEEPGRAM_API_KEY'),
53
86
  openrouterKeyEnv: z.string().default('OPENROUTER_API_KEY'),
@@ -199,26 +232,94 @@ export function apply(ctx, config) {
199
232
  return runChain(order, providers)
200
233
  }
201
234
 
235
+ // Синтез одной фразы с постановкой в очередь. Порядок сохраняется: номер
236
+ // выдаётся сразу, а не когда ответит провайдер, — иначе короткие фразы
237
+ // обгоняли бы длинные.
238
+ function speakPiece(sid, text, cfg, kind) {
239
+ if (!text) return
240
+ const id = `u${++utteranceSeq}`
241
+ const controller = new AbortController()
242
+ const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
243
+ reserve(id)
244
+ synthesize(text, cfg, controller.signal)
245
+ .then((out) => settle(id, {
246
+ id,
247
+ sessionId: sid,
248
+ kind: kind || 'speech',
249
+ provider: out.provider,
250
+ mime: out.mime,
251
+ audioBase64: Buffer.from(out.audio).toString('base64'),
252
+ tookMs: out.tookMs,
253
+ }))
254
+ .catch((err) => settle(id, { id, sessionId: sid, kind: kind || 'speech', text, error: String(err && err.message || err) }))
255
+ .finally(() => clearTimeout(timer))
256
+ }
257
+
258
+ // Объявление: сначала короткий сигнал, затем фраза. Сигнал — не файл, его
259
+ // рисует сам браузер, поэтому ничего не качается и не хранится.
260
+ function announce(sid, text, cfg) {
261
+ if (cfg.chime && cfg.chime !== 'none') {
262
+ enqueue({ id: `u${++utteranceSeq}`, sessionId: sid, kind: 'chime', chime: cfg.chime })
263
+ }
264
+ speakPiece(sid, text, cfg, 'notice')
265
+ }
266
+
202
267
  function enqueue(item) {
203
268
  pending.push(item)
204
269
  const max = live().maxQueue || 8
205
270
  while (pending.length > max) pending.shift()
206
271
  }
207
272
 
273
+ // Место в очереди занимается до синтеза, чтобы порядок фраз совпадал с
274
+ // порядком речи, а не с тем, кто быстрее ответил.
275
+ function reserve(id) {
276
+ enqueue({ id, kind: 'reserved' })
277
+ }
278
+
279
+ function settle(id, item) {
280
+ const at = pending.findIndex((row) => row.id === id)
281
+ if (at === -1) { enqueue(item); return }
282
+ pending[at] = item
283
+ }
284
+
208
285
  ctx.effect(() => ctx.on('session/event', (session, event) => {
209
286
  const cfg = live()
210
287
  if (!cfg.speakReplies) return
211
288
  const sid = session && session.id
212
289
  if (!sid) return
290
+ // Агент упёрся в подтверждение и ждёт человека. Об этом стоит сказать
291
+ // вслух: иначе про остановку узнаёшь, когда вернёшься к экрану.
292
+ if (event.type === 'approval/asked') {
293
+ if (!cfg.announceApproval) return
294
+ const tool = event.data && event.data.toolName
295
+ announce(sid, cfg.approvalText + (tool ? ': ' + tool : ''), cfg)
296
+ return
297
+ }
298
+ if (event.type === 'question/requested') {
299
+ if (!cfg.announceApproval || !cfg.questionText) return
300
+ announce(sid, cfg.questionText, cfg)
301
+ return
302
+ }
303
+
213
304
  if (event.type === 'assistant/message') {
214
305
  const text = assistantText(event.data && event.data.message)
215
306
  if (!text) return
307
+ // Читаем по ходу: каждый пришедший кусок ответа озвучивается сразу,
308
+ // предложениями, и очередь проигрывается по порядку. Ждать конца хода
309
+ // значит молчать всё время, пока агент работает.
310
+ if (cfg.speakAsItGoes) {
311
+ const ready = stripForSpeech(text, cfg.maxChars)
312
+ for (const piece of splitSentences(ready, cfg.sentenceChars)) speakPiece(sid, piece, cfg)
313
+ return
314
+ }
216
315
  const cur = collectors.get(sid) || { parts: [] }
217
316
  cur.parts.push(text)
218
317
  collectors.set(sid, cur)
219
318
  return
220
319
  }
221
320
  if (event.type !== 'turn/end') return
321
+ // При чтении по ходу к концу хода уже всё сказано.
322
+ if (cfg.speakAsItGoes) { collectors.delete(sid); return }
222
323
  const cur = collectors.get(sid)
223
324
  collectors.delete(sid)
224
325
  const raw = cur && Array.isArray(cur.parts) ? cur.parts.join('\n') : ''
@@ -257,6 +358,8 @@ export function apply(ctx, config) {
257
358
  writeJson(res, 200, {
258
359
  ok: true,
259
360
  speakReplies: cfg.speakReplies,
361
+ rate: cfg.rate,
362
+ chime: cfg.chime,
260
363
  language: cfg.language,
261
364
  chain: cfg.chain,
262
365
  providers: PROVIDER_KEYS,
package/lib/providers.js CHANGED
@@ -2,10 +2,23 @@ import { edgeSpeak, espeakSpeak, piperSpeak } from './local.js'
2
2
 
3
3
  export const PROVIDER_KEYS = [
4
4
  'openai', 'elevenlabs', 'google', 'azure', 'groq', 'deepgram', 'openrouter',
5
+ 'siliconflow', 'deepinfra', 'fireworks',
5
6
  'edge', 'piper', 'espeak',
6
7
  ]
7
8
 
9
+ // Провайдеры, говорящие на языке OpenAI: тот же путь /audio/speech, отличаются
10
+ // только адресом, моделью и именем ключа. Каждый адрес проверен запросом без
11
+ // ключа — все отвечают 401, то есть путь существует и ждёт ключ.
12
+ export const OPENAI_COMPATIBLE = {
13
+ siliconflow: { baseUrl: 'https://api.siliconflow.cn/v1', keyEnv: 'SILICONFLOW_API_KEY' },
14
+ deepinfra: { baseUrl: 'https://api.deepinfra.com/v1/openai', keyEnv: 'DEEPINFRA_API_KEY' },
15
+ fireworks: { baseUrl: 'https://api.fireworks.ai/inference/v1', keyEnv: 'FIREWORKS_API_KEY' },
16
+ }
17
+
8
18
  export const DEFAULT_MODELS = {
19
+ siliconflow: 'FunAudioLLM/CosyVoice2-0.5B',
20
+ deepinfra: 'hexgrad/Kokoro-82M',
21
+ fireworks: 'kokoro',
9
22
  openai: 'gpt-4o-mini-tts',
10
23
  elevenlabs: 'eleven_multilingual_v2',
11
24
  google: 'gemini-2.5-flash-preview-tts',
@@ -19,6 +32,9 @@ export const DEFAULT_MODELS = {
19
32
  }
20
33
 
21
34
  export const DEFAULT_VOICES = {
35
+ siliconflow: 'FunAudioLLM/CosyVoice2-0.5B:alex',
36
+ deepinfra: 'af_bella',
37
+ fireworks: 'af_bella',
22
38
  openai: 'alloy',
23
39
  elevenlabs: '21m00Tcm4TlvDq8ikWAM',
24
40
  google: 'Kore',
@@ -64,6 +80,32 @@ export function makeProviders(deps, req) {
64
80
  return { ok: audio.length > 0, provider: 'openai', audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
65
81
  }
66
82
 
83
+ // Одна реализация на всех, кто говорит на языке OpenAI: адрес и имя ключа
84
+ // берутся из таблицы, остальное совпадает буква в букву.
85
+ function openaiCompatible(key) {
86
+ const spec = OPENAI_COMPATIBLE[key]
87
+ return async function speak() {
88
+ const keyEnv = (cfg[key + 'KeyEnv'] || spec.keyEnv)
89
+ const token = await resolveKey(keyEnv)
90
+ if (!token) return { ok: false, provider: key, reason: `no ${keyEnv}` }
91
+ const base = cfg[key + 'BaseUrl'] || spec.baseUrl
92
+ const res = await fetchImpl(base + '/audio/speech', {
93
+ method: 'POST',
94
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
95
+ body: JSON.stringify({
96
+ model: pick(DEFAULT_MODELS, models, key),
97
+ input: text,
98
+ voice: pick(DEFAULT_VOICES, voices, key),
99
+ response_format: 'mp3',
100
+ }),
101
+ signal,
102
+ })
103
+ if (!res.ok) throw new Error(`${key} HTTP ${res.status}`)
104
+ const audio = asBuffer(Buffer.from(await res.arrayBuffer()))
105
+ return { ok: audio.length > 0, provider: key, audio, mime: 'audio/mpeg', reason: audio.length ? '' : 'empty audio' }
106
+ }
107
+ }
108
+
67
109
  async function elevenlabs() {
68
110
  const key = await resolveKey(cfg.elevenlabsKeyEnv)
69
111
  if (!key) return { ok: false, provider: 'elevenlabs', reason: `no ${cfg.elevenlabsKeyEnv}` }
@@ -217,7 +259,10 @@ export function makeProviders(deps, req) {
217
259
  }
218
260
  }
219
261
 
220
- return { openai, elevenlabs, google, azure, groq, deepgram, openrouter, edge, piper, espeak }
262
+ const compatible = {}
263
+ for (const key of Object.keys(OPENAI_COMPATIBLE)) compatible[key] = openaiCompatible(key)
264
+
265
+ return { openai, elevenlabs, google, azure, groq, deepgram, openrouter, ...compatible, edge, piper, espeak }
221
266
  }
222
267
 
223
268
  function escapeXml(s) {
package/lib/text.js CHANGED
@@ -29,3 +29,40 @@ export function assistantText(message) {
29
29
  if (!message) return ''
30
30
  return blocksToText(message.content)
31
31
  }
32
+
33
+ /**
34
+ * Режет текст на куски по границам предложений.
35
+ *
36
+ * Нужно, чтобы чтение начиналось почти сразу: длинный ответ синтезируется
37
+ * целиком долго, а первое предложение — быстро. Куски короче нижнего порога
38
+ * прилипают к следующему: отдельная фраза «Да.» звучит рвано.
39
+ *
40
+ * @param text {string} уже очищенный текст
41
+ * @param maxChars {number} верхняя граница куска
42
+ * @returns {string[]}
43
+ */
44
+ export function splitSentences(text, maxChars) {
45
+ const limit = Number(maxChars) > 0 ? Number(maxChars) : 320
46
+ const min = Math.min(60, Math.floor(limit / 3))
47
+ const source = String(text || '').trim()
48
+ if (!source) return []
49
+
50
+ const pieces = []
51
+ let current = ''
52
+ // Границей считаем точку, вопрос, восклицание, многоточие и перевод строки.
53
+ for (const part of source.split(/(?<=[.!?…])\s+|\n+/)) {
54
+ const chunk = String(part || '').trim()
55
+ if (!chunk) continue
56
+ if (!current) { current = chunk } else if (current.length < min) { current += ' ' + chunk }
57
+ else { pieces.push(current); current = chunk }
58
+ // Кусок длиннее предела режем по словам: провайдеры не любят простыни.
59
+ while (current.length > limit) {
60
+ let cut = current.lastIndexOf(' ', limit)
61
+ if (cut < min) cut = limit
62
+ pieces.push(current.slice(0, cut).trim())
63
+ current = current.slice(cut).trim()
64
+ }
65
+ }
66
+ if (current) pieces.push(current)
67
+ return pieces.filter(Boolean)
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-tts",
3
- "version": "0.1.1",
3
+ "version": "0.2.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",
@@ -31,7 +31,7 @@
31
31
  ],
32
32
  "repository": {
33
33
  "type": "git",
34
- "url": "https://github.com/GooDAnDReaDY/dsh-tts.git"
34
+ "url": "git+https://github.com/GooDAnDReaDY/dsh-tts.git"
35
35
  },
36
36
  "homepage": "https://github.com/GooDAnDReaDY/dsh-tts",
37
37
  "bugs": {
@@ -59,5 +59,8 @@
59
59
  "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
60
60
  "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
61
61
  "@deepseek-ai/schemastery": "^3.18.1"
62
+ },
63
+ "publishConfig": {
64
+ "access": "public"
62
65
  }
63
66
  }