@goodandready/dsh-tts 0.2.1 → 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/index.js CHANGED
@@ -83,6 +83,13 @@ export const Config = z.object({
83
83
  azureKeyEnv: z.string().default('AZURE_SPEECH_KEY'),
84
84
  azureRegion: z.string().default('')
85
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'),
86
93
  siliconflowKeyEnv: z.string().description('Credential holding the SiliconFlow key.').default('SILICONFLOW_API_KEY'),
87
94
  deepinfraKeyEnv: z.string().description('Credential holding the DeepInfra key.').default('DEEPINFRA_API_KEY'),
88
95
  fireworksKeyEnv: z.string().description('Credential holding the Fireworks key.').default('FIREWORKS_API_KEY'),
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.1",
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",