@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 +194 -18
- package/lib/index.js +104 -1
- package/lib/providers.js +46 -1
- package/lib/text.js +37 -0
- package/package.json +5 -2
package/lib/client.js
CHANGED
|
@@ -53,22 +53,141 @@ window.__ModuleLoader__.load({
|
|
|
53
53
|
document.head.appendChild(tag)
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
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
|
|
59
|
-
if (
|
|
156
|
+
async function drainQueue() {
|
|
157
|
+
if (player.busy) return
|
|
158
|
+
player.busy = true
|
|
159
|
+
playerChanged()
|
|
60
160
|
try {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 (
|
|
86
|
-
|
|
206
|
+
for (const item of items) {
|
|
207
|
+
// Место в очереди занято, но синтез ещё идёт — подождём следующего
|
|
208
|
+
// опроса, иначе порядок фраз рассыплется.
|
|
209
|
+
if (item.kind === 'reserved') break
|
|
87
210
|
player.after = item.id
|
|
88
|
-
|
|
211
|
+
player.queue.push(item)
|
|
89
212
|
}
|
|
90
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|