@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 +217 -18
- package/lib/index.js +110 -1
- package/lib/providers.js +46 -1
- package/lib/text.js +37 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -53,22 +53,160 @@ 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
|
+
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 (
|
|
86
|
-
|
|
226
|
+
for (const item of items) {
|
|
227
|
+
// Место в очереди занято, но синтез ещё идёт — подождём следующего
|
|
228
|
+
// опроса, иначе порядок фраз рассыплется.
|
|
229
|
+
if (item.kind === 'reserved') break
|
|
87
230
|
player.after = item.id
|
|
88
|
-
|
|
231
|
+
player.queue.push(item)
|
|
89
232
|
}
|
|
90
|
-
|
|
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
|
-
|
|
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
|
|
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",
|