@goodandready/dsh-tts 0.2.0 → 0.3.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 +23 -0
- package/lib/index.js +13 -0
- package/lib/local.js +27 -0
- package/lib/providers.js +63 -4
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -66,6 +66,7 @@ window.__ModuleLoader__.load({
|
|
|
66
66
|
rate: 1,
|
|
67
67
|
chime: 'ding',
|
|
68
68
|
queue: [],
|
|
69
|
+
bargeIn: true,
|
|
69
70
|
busy: false,
|
|
70
71
|
paused: false,
|
|
71
72
|
listeners: new Set(),
|
|
@@ -190,6 +191,24 @@ window.__ModuleLoader__.load({
|
|
|
190
191
|
playerChanged()
|
|
191
192
|
}
|
|
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)
|
|
210
|
+
}
|
|
211
|
+
|
|
193
212
|
async function pollPending() {
|
|
194
213
|
try {
|
|
195
214
|
const st = await fetch('/dsh-tts/status', { cache: 'no-store' })
|
|
@@ -198,6 +217,7 @@ window.__ModuleLoader__.load({
|
|
|
198
217
|
player.enabled = !!(meta && meta.speakReplies)
|
|
199
218
|
if (meta && typeof meta.rate === 'number') player.rate = meta.rate
|
|
200
219
|
if (meta && meta.chime) player.chime = meta.chime
|
|
220
|
+
if (meta && typeof meta.bargeIn === 'boolean') player.bargeIn = meta.bargeIn
|
|
201
221
|
if (!player.enabled) return
|
|
202
222
|
const res = await fetch('/dsh-tts/pending?after=' + encodeURIComponent(player.after || ''), { cache: 'no-store' })
|
|
203
223
|
if (!res.ok) return
|
|
@@ -420,6 +440,8 @@ window.__ModuleLoader__.load({
|
|
|
420
440
|
boolField('speakAsItGoes', 'Speak as it goes',
|
|
421
441
|
'Read each reply the moment it lands, sentence by sentence, instead of waiting for the whole turn.'),
|
|
422
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.'),
|
|
423
445
|
boolField('announceApproval', 'Announce approvals',
|
|
424
446
|
'Say it out loud and play a chime when the agent stops for an approval or asks a question.'),
|
|
425
447
|
textField('approvalText', 'Approval wording', 'Said when an approval is asked for; the tool name is appended.'),
|
|
@@ -479,6 +501,7 @@ window.__ModuleLoader__.load({
|
|
|
479
501
|
exports.apply = function apply(ctx) {
|
|
480
502
|
registerSettings(ctx)
|
|
481
503
|
registerSpeaker(ctx)
|
|
504
|
+
ctx.effect(() => listenForVoice(), 'dsh-tts: замолкать, когда человек заговорил')
|
|
482
505
|
setInterval(pollPending, 1000)
|
|
483
506
|
}
|
|
484
507
|
return module.exports
|
package/lib/index.js
CHANGED
|
@@ -45,6 +45,11 @@ export const Config = z.object({
|
|
|
45
45
|
.number()
|
|
46
46
|
.description('Playback speed, 0.5 to 2. Synthesis is untouched; the browser plays faster or slower.')
|
|
47
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),
|
|
48
53
|
announceApproval: z
|
|
49
54
|
.boolean()
|
|
50
55
|
.description('Say out loud when the agent stops and waits for an approval, and play a short chime. '
|
|
@@ -78,6 +83,13 @@ export const Config = z.object({
|
|
|
78
83
|
azureKeyEnv: z.string().default('AZURE_SPEECH_KEY'),
|
|
79
84
|
azureRegion: z.string().default('')
|
|
80
85
|
.description('Azure Speech region, e.g. eastus. Required for the azure provider.'),
|
|
86
|
+
mimoKeyEnv: z.string().description('Credential holding the Xiaomi MiMo key.').default('MIMO_API_KEY'),
|
|
87
|
+
mimoBaseUrl: z.string().description('MiMo API root. Synthesis goes through its chat endpoint.').default('https://api.xiaomimimo.com/v1'),
|
|
88
|
+
mimoFormat: z.string().description('MiMo output format: mp3 or wav.').default('mp3'),
|
|
89
|
+
minimaxBin: z.string()
|
|
90
|
+
.description('MiniMax CLI, looked up in PATH unless absolute. Install and log in separately; '
|
|
91
|
+
+ 'without it the provider declines and the chain moves on.')
|
|
92
|
+
.default('mmx'),
|
|
81
93
|
siliconflowKeyEnv: z.string().description('Credential holding the SiliconFlow key.').default('SILICONFLOW_API_KEY'),
|
|
82
94
|
deepinfraKeyEnv: z.string().description('Credential holding the DeepInfra key.').default('DEEPINFRA_API_KEY'),
|
|
83
95
|
fireworksKeyEnv: z.string().description('Credential holding the Fireworks key.').default('FIREWORKS_API_KEY'),
|
|
@@ -360,6 +372,7 @@ export function apply(ctx, config) {
|
|
|
360
372
|
speakReplies: cfg.speakReplies,
|
|
361
373
|
rate: cfg.rate,
|
|
362
374
|
chime: cfg.chime,
|
|
375
|
+
bargeIn: cfg.bargeIn,
|
|
363
376
|
language: cfg.language,
|
|
364
377
|
chain: cfg.chain,
|
|
365
378
|
providers: PROVIDER_KEYS,
|
package/lib/local.js
CHANGED
|
@@ -81,3 +81,30 @@ export async function edgeSpeak(text, { bin, voice, timeoutMs }) {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* MiniMax через их консольную утилиту.
|
|
87
|
+
*
|
|
88
|
+
* Прямого HTTP тут намеренно нет: у MiniMax свой протокол с отдельным
|
|
89
|
+
* идентификатором группы и своей формой ответа, а официальная утилита уже
|
|
90
|
+
* умеет и авторизацию, и выбор голоса. Она же хранит вход — плагину не нужно
|
|
91
|
+
* знать ни ключа, ни устройства их API.
|
|
92
|
+
*
|
|
93
|
+
* Утилита ставится отдельно и один раз логинится; без неё провайдер честно
|
|
94
|
+
* отказывает, и цепочка идёт дальше.
|
|
95
|
+
*/
|
|
96
|
+
export async function minimaxSpeak(text, { bin, voice, model, timeoutMs }) {
|
|
97
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), 'dsh-tts-minimax-'))
|
|
98
|
+
const outFile = path.join(dir, 'out.mp3')
|
|
99
|
+
try {
|
|
100
|
+
const args = ['speech', 'synthesize', '--text', text, '--out', outFile, '--quiet', '--non-interactive']
|
|
101
|
+
if (voice) args.push('--voice', voice)
|
|
102
|
+
if (model) args.push('--model', model)
|
|
103
|
+
await spawnCollect(bin || 'mmx', args, { timeoutMs })
|
|
104
|
+
const bytes = await readFile(outFile)
|
|
105
|
+
if (!bytes || bytes.length < 64) throw new Error('minimax produced empty audio')
|
|
106
|
+
return { audio: bytes, mime: 'audio/mpeg' }
|
|
107
|
+
} finally {
|
|
108
|
+
await rm(dir, { recursive: true, force: true })
|
|
109
|
+
}
|
|
110
|
+
}
|
package/lib/providers.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { edgeSpeak, espeakSpeak, piperSpeak } from './local.js'
|
|
1
|
+
import { edgeSpeak, espeakSpeak, minimaxSpeak, 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',
|
|
6
|
-
'edge', 'piper', 'espeak',
|
|
5
|
+
'siliconflow', 'deepinfra', 'fireworks', 'mimo',
|
|
6
|
+
'edge', 'piper', 'espeak', 'minimax',
|
|
7
7
|
]
|
|
8
8
|
|
|
9
9
|
// Провайдеры, говорящие на языке OpenAI: тот же путь /audio/speech, отличаются
|
|
@@ -16,6 +16,8 @@ export const OPENAI_COMPATIBLE = {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export const DEFAULT_MODELS = {
|
|
19
|
+
mimo: 'mimo-v2.5-tts',
|
|
20
|
+
minimax: '',
|
|
19
21
|
siliconflow: 'FunAudioLLM/CosyVoice2-0.5B',
|
|
20
22
|
deepinfra: 'hexgrad/Kokoro-82M',
|
|
21
23
|
fireworks: 'kokoro',
|
|
@@ -32,6 +34,9 @@ export const DEFAULT_MODELS = {
|
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
export const DEFAULT_VOICES = {
|
|
37
|
+
// Голоса у MiMo названы по-китайски; «冰糖» — их же значение по умолчанию.
|
|
38
|
+
mimo: '冰糖',
|
|
39
|
+
minimax: '',
|
|
35
40
|
siliconflow: 'FunAudioLLM/CosyVoice2-0.5B:alex',
|
|
36
41
|
deepinfra: 'af_bella',
|
|
37
42
|
fireworks: 'af_bella',
|
|
@@ -106,6 +111,60 @@ export function makeProviders(deps, req) {
|
|
|
106
111
|
}
|
|
107
112
|
}
|
|
108
113
|
|
|
114
|
+
// Xiaomi MiMo. Синтез идёт через чат: текст кладётся сообщением, а голос и
|
|
115
|
+
// формат — отдельным полем; готовый звук приезжает в ответе base64.
|
|
116
|
+
async function mimo() {
|
|
117
|
+
const key = await resolveKey(cfg.mimoKeyEnv)
|
|
118
|
+
if (!key) return { ok: false, provider: 'mimo', reason: `no ${cfg.mimoKeyEnv}` }
|
|
119
|
+
const base = (cfg.mimoBaseUrl || 'https://api.xiaomimimo.com/v1').replace(/\/+$/, '')
|
|
120
|
+
const format = cfg.mimoFormat === 'wav' ? 'wav' : 'mp3'
|
|
121
|
+
const res = await fetchImpl(base + '/chat/completions', {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json', accept: 'application/json' },
|
|
124
|
+
body: JSON.stringify({
|
|
125
|
+
model: pick(DEFAULT_MODELS, models, 'mimo'),
|
|
126
|
+
messages: [{ role: 'assistant', content: text }],
|
|
127
|
+
audio: { format, voice: pick(DEFAULT_VOICES, voices, 'mimo') },
|
|
128
|
+
stream: false,
|
|
129
|
+
}),
|
|
130
|
+
signal,
|
|
131
|
+
})
|
|
132
|
+
const data = await res.json().catch(() => ({}))
|
|
133
|
+
if (!res.ok) {
|
|
134
|
+
const detail = data && data.error && data.error.message
|
|
135
|
+
throw new Error(`MiMo HTTP ${res.status}${detail ? ': ' + detail : ''}`)
|
|
136
|
+
}
|
|
137
|
+
const encoded = data && data.choices && data.choices[0]
|
|
138
|
+
&& data.choices[0].message && data.choices[0].message.audio
|
|
139
|
+
&& data.choices[0].message.audio.data
|
|
140
|
+
if (typeof encoded !== 'string' || !encoded) {
|
|
141
|
+
return { ok: false, provider: 'mimo', reason: 'в ответе нет choices[0].message.audio.data' }
|
|
142
|
+
}
|
|
143
|
+
const audio = Buffer.from(encoded, 'base64')
|
|
144
|
+
return {
|
|
145
|
+
ok: audio.length > 0,
|
|
146
|
+
provider: 'mimo',
|
|
147
|
+
audio,
|
|
148
|
+
mime: format === 'wav' ? 'audio/wav' : 'audio/mpeg',
|
|
149
|
+
reason: audio.length ? '' : 'empty audio',
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// MiniMax — через их утилиту: своего протокола здесь нет намеренно.
|
|
154
|
+
async function minimax() {
|
|
155
|
+
try {
|
|
156
|
+
const out = await minimaxSpeak(text, {
|
|
157
|
+
bin: cfg.minimaxBin,
|
|
158
|
+
voice: pick(DEFAULT_VOICES, voices, 'minimax'),
|
|
159
|
+
model: pick(DEFAULT_MODELS, models, 'minimax'),
|
|
160
|
+
timeoutMs: cfg.timeoutMs,
|
|
161
|
+
})
|
|
162
|
+
return { ok: true, provider: 'minimax', audio: out.audio, mime: out.mime }
|
|
163
|
+
} catch (e) {
|
|
164
|
+
return { ok: false, provider: 'minimax', reason: String(e && e.message || e) }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
109
168
|
async function elevenlabs() {
|
|
110
169
|
const key = await resolveKey(cfg.elevenlabsKeyEnv)
|
|
111
170
|
if (!key) return { ok: false, provider: 'elevenlabs', reason: `no ${cfg.elevenlabsKeyEnv}` }
|
|
@@ -262,7 +321,7 @@ export function makeProviders(deps, req) {
|
|
|
262
321
|
const compatible = {}
|
|
263
322
|
for (const key of Object.keys(OPENAI_COMPATIBLE)) compatible[key] = openaiCompatible(key)
|
|
264
323
|
|
|
265
|
-
return { openai, elevenlabs, google, azure, groq, deepgram, openrouter, ...compatible, edge, piper, espeak }
|
|
324
|
+
return { openai, elevenlabs, google, azure, groq, deepgram, openrouter, mimo, ...compatible, edge, piper, espeak, minimax }
|
|
266
325
|
}
|
|
267
326
|
|
|
268
327
|
function escapeXml(s) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-tts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|