@goodandready/dsh-tts 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -53,22 +53,160 @@ 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
+ bargeIn: true,
70
+ busy: false,
71
+ paused: false,
72
+ listeners: new Set(),
73
+ }
74
+
75
+ function playerChanged() {
76
+ for (const listener of [...player.listeners]) {
77
+ try { listener() } catch (listenerFailure) { /* чужой слушатель не наша забота */ }
78
+ }
79
+ }
80
+
81
+ function usePlayer() {
82
+ const [, force] = React.useReducer((n) => n + 1, 0)
83
+ React.useEffect(() => {
84
+ player.listeners.add(force)
85
+ return () => { player.listeners.delete(force) }
86
+ }, [])
87
+ return player
88
+ }
89
+
90
+ // Короткий сигнал рисуем сами: файл не нужен, значит нечего качать,
91
+ // хранить и настраивать.
92
+ function playChime(kind) {
93
+ return new Promise((resolve) => {
94
+ try {
95
+ const AC = window.AudioContext || window.webkitAudioContext
96
+ if (!AC) { resolve(); return }
97
+ const audioCtx = new AC()
98
+ const gain = audioCtx.createGain()
99
+ gain.connect(audioCtx.destination)
100
+ const tones = kind === 'beep' ? [880, 880] : [660, 990]
101
+ let at = audioCtx.currentTime
102
+ for (const freq of tones) {
103
+ const osc = audioCtx.createOscillator()
104
+ osc.frequency.value = freq
105
+ osc.connect(gain)
106
+ gain.gain.setValueAtTime(0.0001, at)
107
+ gain.gain.exponentialRampToValueAtTime(0.25, at + 0.02)
108
+ gain.gain.exponentialRampToValueAtTime(0.0001, at + 0.18)
109
+ osc.start(at)
110
+ osc.stop(at + 0.2)
111
+ at += 0.22
112
+ }
113
+ setTimeout(() => { try { audioCtx.close() } catch (already) { /* закрыт */ } resolve() }, 600)
114
+ } catch (noAudio) { resolve() }
115
+ })
116
+ }
57
117
 
58
- function playItem(item) {
59
- if (!item || item.error || !item.audioBase64) return
118
+ // Провайдер не смог — читаем голосом браузера. Хуже по звучанию, но
119
+ // молчание хуже вдвойне: человек не узнает, что ответ готов.
120
+ function speakInBrowser(text) {
121
+ return new Promise((resolve) => {
122
+ try {
123
+ if (!window.speechSynthesis || !text) { resolve(); return }
124
+ const utterance = new SpeechSynthesisUtterance(String(text))
125
+ utterance.rate = Math.max(0.5, Math.min(2, player.rate || 1))
126
+ utterance.onend = () => resolve()
127
+ utterance.onerror = () => resolve()
128
+ window.speechSynthesis.speak(utterance)
129
+ } catch (noSpeech) { resolve() }
130
+ })
131
+ }
132
+
133
+ function playAudio(item) {
134
+ return new Promise((resolve) => {
135
+ try {
136
+ const bin = atob(item.audioBase64)
137
+ const bytes = new Uint8Array(bin.length)
138
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
139
+ const blob = new Blob([bytes], { type: item.mime || 'audio/mpeg' })
140
+ const url = URL.createObjectURL(blob)
141
+ const audio = new Audio(url)
142
+ audio.playbackRate = Math.max(0.5, Math.min(2, player.rate || 1))
143
+ player.audio = audio
144
+ playerChanged()
145
+ const done = () => { URL.revokeObjectURL(url); player.audio = null; playerChanged(); resolve() }
146
+ audio.onended = done
147
+ audio.onerror = done
148
+ audio.play().catch(() => {
149
+ // Браузер не даёт играть без действия человека — не считаем это
150
+ // поломкой, просто ничего не звучит.
151
+ done()
152
+ })
153
+ } catch (cannotDecode) { resolve() }
154
+ })
155
+ }
156
+
157
+ async function drainQueue() {
158
+ if (player.busy) return
159
+ player.busy = true
160
+ playerChanged()
60
161
  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 */ }
162
+ while (player.queue.length) {
163
+ if (player.paused) break
164
+ const item = player.queue.shift()
165
+ if (!item || item.kind === 'reserved') continue
166
+ if (item.kind === 'chime') { await playChime(item.chime || player.chime); continue }
167
+ if (item.audioBase64) await playAudio(item)
168
+ else if (item.error && item.text) await speakInBrowser(item.text)
169
+ }
170
+ } finally {
171
+ player.busy = false
172
+ playerChanged()
173
+ }
174
+ }
175
+
176
+ function stopPlayback() {
177
+ player.queue.length = 0
178
+ player.paused = false
179
+ if (player.audio) { try { player.audio.pause() } catch (already) { /* стоит */ } }
180
+ player.audio = null
181
+ try { if (window.speechSynthesis) window.speechSynthesis.cancel() } catch (noSpeech) { /* нет синтеза */ }
182
+ playerChanged()
183
+ }
184
+
185
+ function togglePause() {
186
+ player.paused = !player.paused
187
+ if (player.audio) {
188
+ try { player.paused ? player.audio.pause() : player.audio.play() } catch (already) { /* всё равно */ }
189
+ }
190
+ if (!player.paused) drainQueue()
191
+ playerChanged()
192
+ }
193
+
194
+ // Человек заговорил — замолкаем.
195
+ //
196
+ // Событие приходит из плагина голосового ввода, но никакой связи с ним
197
+ // нет: если его не установили, событие просто никогда не придёт. Слушаем
198
+ // окно, а не чужую службу, ровно поэтому.
199
+ function listenForVoice() {
200
+ if (typeof window === 'undefined') return () => {}
201
+ const onSpeaking = (event) => {
202
+ const phase = event && event.detail && event.detail.phase
203
+ if (phase !== 'start') return
204
+ if (!player.bargeIn) return
205
+ if (!player.audio && !player.queue.length && !player.busy) return
206
+ stopPlayback()
207
+ }
208
+ window.addEventListener('dsh-voice:speaking', onSpeaking)
209
+ return () => window.removeEventListener('dsh-voice:speaking', onSpeaking)
72
210
  }
73
211
 
74
212
  async function pollPending() {
@@ -77,17 +215,23 @@ window.__ModuleLoader__.load({
77
215
  if (!st.ok) return
78
216
  const meta = await st.json()
79
217
  player.enabled = !!(meta && meta.speakReplies)
218
+ if (meta && typeof meta.rate === 'number') player.rate = meta.rate
219
+ if (meta && meta.chime) player.chime = meta.chime
220
+ if (meta && typeof meta.bargeIn === 'boolean') player.bargeIn = meta.bargeIn
80
221
  if (!player.enabled) return
81
222
  const res = await fetch('/dsh-tts/pending?after=' + encodeURIComponent(player.after || ''), { cache: 'no-store' })
82
223
  if (!res.ok) return
83
224
  const data = await res.json()
84
225
  const items = data && Array.isArray(data.items) ? data.items : []
85
- for (let i = 0; i < items.length; i++) {
86
- const item = items[i]
226
+ for (const item of items) {
227
+ // Место в очереди занято, но синтез ещё идёт — подождём следующего
228
+ // опроса, иначе порядок фраз рассыплется.
229
+ if (item.kind === 'reserved') break
87
230
  player.after = item.id
88
- playItem(item)
231
+ player.queue.push(item)
89
232
  }
90
- } catch (e) { /* host restarting */ }
233
+ if (player.queue.length) drainQueue()
234
+ } catch (hostRestarting) { /* хост перезапускается */ }
91
235
  }
92
236
 
93
237
  function credOf(map, provider) {
@@ -243,6 +387,21 @@ window.__ModuleLoader__.load({
243
387
  commitKey(provider, raw).catch((e) => setErr(String(e && e.message ? e.message : e)))
244
388
  }
245
389
 
390
+ const boolField = (key, label, hint) => React.createElement('label', { className: 'dts-field' }, label,
391
+ React.createElement('input', {
392
+ type: 'checkbox', checked: !!(draft && draft[key]), disabled: !writable,
393
+ onChange: (e) => setTop(key, e.target.checked),
394
+ }),
395
+ React.createElement('span', { className: 'dts-sub' }, hint))
396
+
397
+ const numberField = (key, label, hint, step) => React.createElement('label', { className: 'dts-field' }, label,
398
+ React.createElement('input', {
399
+ type: 'number', step: step || 1,
400
+ value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
401
+ onChange: (e) => setTop(key, Number(e.target.value)),
402
+ }),
403
+ React.createElement('span', { className: 'dts-sub' }, hint))
404
+
246
405
  const textField = (key, label, hint) => React.createElement('label', { className: 'dts-field' }, label,
247
406
  React.createElement('input', {
248
407
  value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
@@ -278,6 +437,16 @@ window.__ModuleLoader__.load({
278
437
  ),
279
438
  React.createElement('div', { className: 'dts-block' },
280
439
  React.createElement('div', { className: 'dts-h' }, 'General'),
440
+ boolField('speakAsItGoes', 'Speak as it goes',
441
+ 'Read each reply the moment it lands, sentence by sentence, instead of waiting for the whole turn.'),
442
+ numberField('rate', 'Playback speed', '0.5 to 2. Synthesis is untouched; the browser plays faster or slower.', 0.1),
443
+ boolField('bargeIn', 'Fall silent when you speak',
444
+ 'Stop reading the moment the microphone opens.'),
445
+ boolField('announceApproval', 'Announce approvals',
446
+ 'Say it out loud and play a chime when the agent stops for an approval or asks a question.'),
447
+ textField('approvalText', 'Approval wording', 'Said when an approval is asked for; the tool name is appended.'),
448
+ textField('questionText', 'Question wording', 'Said when the agent asks a question. Empty disables it.'),
449
+ textField('chime', 'Chime', 'ding, beep or none.'),
281
450
  textField('language', 'Language', 'Hint for voices that need a locale (eSpeak, Azure SSML).'),
282
451
  textField('piperModel', 'Piper model path', 'Absolute path to an ONNX model. Leave empty to skip Piper.'),
283
452
  textField('piperBin', 'Piper binary', 'Looked up in PATH unless absolute.'),
@@ -293,6 +462,34 @@ window.__ModuleLoader__.load({
293
462
  )
294
463
  }
295
464
 
465
+ // Кнопка в строке ввода: видно, что сейчас читается, и можно остановить.
466
+ function SpeakerControl() {
467
+ const p = usePlayer()
468
+ const active = !!p.audio || p.queue.length > 0 || p.busy
469
+ if (!active) return null
470
+ return React.createElement('div', { style: { display: 'inline-flex', gap: '4px', alignItems: 'center' } },
471
+ React.createElement('button', {
472
+ type: 'button',
473
+ className: 'dts-link',
474
+ title: p.paused ? 'Продолжить чтение' : 'Пауза',
475
+ onClick: togglePause,
476
+ }, p.paused ? '▶' : '❚❚'),
477
+ React.createElement('button', {
478
+ type: 'button',
479
+ className: 'dts-link',
480
+ title: 'Прекратить чтение',
481
+ onClick: stopPlayback,
482
+ }, '■'),
483
+ )
484
+ }
485
+
486
+ function registerSpeaker(ctx) {
487
+ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register(
488
+ { name: 'conversation.input.dock', id: '@goodandready/dsh-tts', order: 5, label: () => 'Чтение' },
489
+ SpeakerControl,
490
+ ))
491
+ }
492
+
296
493
  function registerSettings(ctx) {
297
494
  ctx.slots.inject('settings.section', () => ctx.slots.register(
298
495
  { name: 'settings.section', id: '@goodandready/dsh-tts', order: 32, label: () => 'Speech', inject: () => ({ ctx: ctx }) },
@@ -303,6 +500,8 @@ window.__ModuleLoader__.load({
303
500
  exports.inject = ['slots']
304
501
  exports.apply = function apply(ctx) {
305
502
  registerSettings(ctx)
503
+ registerSpeaker(ctx)
504
+ ctx.effect(() => listenForVoice(), 'dsh-tts: замолкать, когда человек заговорил')
306
505
  setInterval(pollPending, 1000)
307
506
  }
308
507
  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,41 @@ 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
+ bargeIn: z
49
+ .boolean()
50
+ .description('Fall silent the moment the microphone opens. Listening to a reply and talking over it '
51
+ + 'at the same time does not work, and the reply would be recorded along with the voice.')
52
+ .default(true),
53
+ announceApproval: z
54
+ .boolean()
55
+ .description('Say out loud when the agent stops and waits for an approval, and play a short chime. '
56
+ + 'Useful when you walk away from a long run.')
57
+ .default(true),
58
+ approvalText: z
59
+ .string()
60
+ .description('What to say when an approval is asked for. The tool name is appended.')
61
+ .default('Требуется подтверждение'),
62
+ questionText: z
63
+ .string()
64
+ .description('What to say when the agent asks a question. Empty disables it.')
65
+ .default('Агент задал вопрос'),
66
+ chime: z
67
+ .string()
68
+ .description('Short sound before an announcement: ding, beep or none.')
69
+ .default('ding'),
35
70
  maxChars: z.number().default(4000)
36
71
  .description('Longer replies are truncated before synthesis.'),
37
72
  chain: z.array(ChainEntry)
@@ -48,6 +83,9 @@ export const Config = z.object({
48
83
  azureKeyEnv: z.string().default('AZURE_SPEECH_KEY'),
49
84
  azureRegion: z.string().default('')
50
85
  .description('Azure Speech region, e.g. eastus. Required for the azure provider.'),
86
+ siliconflowKeyEnv: z.string().description('Credential holding the SiliconFlow key.').default('SILICONFLOW_API_KEY'),
87
+ deepinfraKeyEnv: z.string().description('Credential holding the DeepInfra key.').default('DEEPINFRA_API_KEY'),
88
+ fireworksKeyEnv: z.string().description('Credential holding the Fireworks key.').default('FIREWORKS_API_KEY'),
51
89
  groqKeyEnv: z.string().default('GROQ_API_KEY'),
52
90
  deepgramKeyEnv: z.string().default('DEEPGRAM_API_KEY'),
53
91
  openrouterKeyEnv: z.string().default('OPENROUTER_API_KEY'),
@@ -199,26 +237,94 @@ export function apply(ctx, config) {
199
237
  return runChain(order, providers)
200
238
  }
201
239
 
240
+ // Синтез одной фразы с постановкой в очередь. Порядок сохраняется: номер
241
+ // выдаётся сразу, а не когда ответит провайдер, — иначе короткие фразы
242
+ // обгоняли бы длинные.
243
+ function speakPiece(sid, text, cfg, kind) {
244
+ if (!text) return
245
+ const id = `u${++utteranceSeq}`
246
+ const controller = new AbortController()
247
+ const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
248
+ reserve(id)
249
+ synthesize(text, cfg, controller.signal)
250
+ .then((out) => settle(id, {
251
+ id,
252
+ sessionId: sid,
253
+ kind: kind || 'speech',
254
+ provider: out.provider,
255
+ mime: out.mime,
256
+ audioBase64: Buffer.from(out.audio).toString('base64'),
257
+ tookMs: out.tookMs,
258
+ }))
259
+ .catch((err) => settle(id, { id, sessionId: sid, kind: kind || 'speech', text, error: String(err && err.message || err) }))
260
+ .finally(() => clearTimeout(timer))
261
+ }
262
+
263
+ // Объявление: сначала короткий сигнал, затем фраза. Сигнал — не файл, его
264
+ // рисует сам браузер, поэтому ничего не качается и не хранится.
265
+ function announce(sid, text, cfg) {
266
+ if (cfg.chime && cfg.chime !== 'none') {
267
+ enqueue({ id: `u${++utteranceSeq}`, sessionId: sid, kind: 'chime', chime: cfg.chime })
268
+ }
269
+ speakPiece(sid, text, cfg, 'notice')
270
+ }
271
+
202
272
  function enqueue(item) {
203
273
  pending.push(item)
204
274
  const max = live().maxQueue || 8
205
275
  while (pending.length > max) pending.shift()
206
276
  }
207
277
 
278
+ // Место в очереди занимается до синтеза, чтобы порядок фраз совпадал с
279
+ // порядком речи, а не с тем, кто быстрее ответил.
280
+ function reserve(id) {
281
+ enqueue({ id, kind: 'reserved' })
282
+ }
283
+
284
+ function settle(id, item) {
285
+ const at = pending.findIndex((row) => row.id === id)
286
+ if (at === -1) { enqueue(item); return }
287
+ pending[at] = item
288
+ }
289
+
208
290
  ctx.effect(() => ctx.on('session/event', (session, event) => {
209
291
  const cfg = live()
210
292
  if (!cfg.speakReplies) return
211
293
  const sid = session && session.id
212
294
  if (!sid) return
295
+ // Агент упёрся в подтверждение и ждёт человека. Об этом стоит сказать
296
+ // вслух: иначе про остановку узнаёшь, когда вернёшься к экрану.
297
+ if (event.type === 'approval/asked') {
298
+ if (!cfg.announceApproval) return
299
+ const tool = event.data && event.data.toolName
300
+ announce(sid, cfg.approvalText + (tool ? ': ' + tool : ''), cfg)
301
+ return
302
+ }
303
+ if (event.type === 'question/requested') {
304
+ if (!cfg.announceApproval || !cfg.questionText) return
305
+ announce(sid, cfg.questionText, cfg)
306
+ return
307
+ }
308
+
213
309
  if (event.type === 'assistant/message') {
214
310
  const text = assistantText(event.data && event.data.message)
215
311
  if (!text) return
312
+ // Читаем по ходу: каждый пришедший кусок ответа озвучивается сразу,
313
+ // предложениями, и очередь проигрывается по порядку. Ждать конца хода
314
+ // значит молчать всё время, пока агент работает.
315
+ if (cfg.speakAsItGoes) {
316
+ const ready = stripForSpeech(text, cfg.maxChars)
317
+ for (const piece of splitSentences(ready, cfg.sentenceChars)) speakPiece(sid, piece, cfg)
318
+ return
319
+ }
216
320
  const cur = collectors.get(sid) || { parts: [] }
217
321
  cur.parts.push(text)
218
322
  collectors.set(sid, cur)
219
323
  return
220
324
  }
221
325
  if (event.type !== 'turn/end') return
326
+ // При чтении по ходу к концу хода уже всё сказано.
327
+ if (cfg.speakAsItGoes) { collectors.delete(sid); return }
222
328
  const cur = collectors.get(sid)
223
329
  collectors.delete(sid)
224
330
  const raw = cur && Array.isArray(cur.parts) ? cur.parts.join('\n') : ''
@@ -257,6 +363,9 @@ export function apply(ctx, config) {
257
363
  writeJson(res, 200, {
258
364
  ok: true,
259
365
  speakReplies: cfg.speakReplies,
366
+ rate: cfg.rate,
367
+ chime: cfg.chime,
368
+ bargeIn: cfg.bargeIn,
260
369
  language: cfg.language,
261
370
  chain: cfg.chain,
262
371
  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.2",
3
+ "version": "0.2.1",
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",