@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 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.2.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",