feat: add tts provider MiniMax (#4309) * feat: add tts provider MiniMax * fix: address code review feedback from @Cohee1207 - Moved inline styles to CSS classes in minimax.js - Removed unused MINIMAX API key from secrets.js - Removed static headers from debug logs - Changed default API host to international endpoint - Sorted TTS providers alphabetically in index.js * feat: improve error handling and add language-specific test sentences based on selected voice * fix: if no language mapping to MiniMax Format should use "auto" * fix: move this minimax-tts.css under /public/scripts/extensions/tts/ * refactor: move minimax api communication to Node backend * chore: unify import style for minimax router * Move css into subfolder * More secure secret key handling * Better UI layout * Finish secrets update * Replace with a normal import * This block shouldn't be here --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

c283025b157d9668b47b4ad3780cb772dbb712b6

An5w1r <167383411+MindsMaster@users.noreply.github.com>

Signed
8 files changed, +1210 -0Ignore whitespace
public/scripts/extensions/tts/css/minimax-tts.css+33 -0
@@ -0,0 +1,33 @@
1+.minimax-custom-item {
2+ display: flex;
3+ justify-content: space-between;
4+ align-items: center;
5+ padding: 8px;
6+ background: #333;
7+ margin: 5px 0;
8+ border-radius: 4px;
9+}
10+
11+.minimax-custom-item-info {
12+ display: flex;
13+ flex-direction: column;
14+}
15+
16+.minimax-custom-item-name {
17+ font-weight: bold;
18+}
19+
20+.minimax-custom-item-details {
21+ color: #aaa;
22+ font-size: 0.9em;
23+}
24+
25+.minimax-custom-item-remove {
26+ padding: 4px 8px;
27+ font-size: 12px;
28+}
29+
30+.minimax-empty-list {
31+ color: #888;
32+ font-style: italic;
33+}
public/scripts/extensions/tts/index.js+2 -0
@@ -32,6 +32,7 @@ import { ChatterboxTtsProvider } from './chatterbox.js';
3232import { KokoroTtsProvider } from './kokoro.js';
3333import { TtsWebuiProvider } from './tts-webui.js';
3434import { PollinationsTtsProvider } from './pollinations.js';
35+import { MiniMaxTtsProvider } from './minimax.js';
3536
3637const UPDATE_INTERVAL = 1000;
3738const wrapper = new ModuleWorkerWrapper(moduleWorker);
@@ -127,6 +128,7 @@ const ttsProviders = {
127128 GSVI: GSVITtsProvider,
128129 'GPT-SoVITS-V2 (Unofficial)': GptSovitsV2Provider,
129130 Kokoro: KokoroTtsProvider,
131+ MiniMax: MiniMaxTtsProvider,
130132 Novel: NovelTtsProvider,
131133 OpenAI: OpenAITtsProvider,
132134 'OpenAI Compatible': OpenAICompatibleTtsProvider,
public/scripts/extensions/tts/minimax.js+935 -0
@@ -0,0 +1,935 @@
1+import { getPreviewString, initVoiceMap, saveTtsProviderSettings } from './index.js';
2+import { event_types, eventSource, getRequestHeaders } from '../../../script.js';
3+import { SECRET_KEYS, secret_state } from '../../secrets.js';
4+import { getBase64Async } from '../../utils.js';
5+
6+export { MiniMaxTtsProvider };
7+
8+class MiniMaxTtsProvider {
9+ //########//
10+ // Config //
11+ //########//
12+
13+ settings;
14+ voices = [];
15+ separator = ' . ';
16+ audioElement = document.createElement('audio');
17+
18+ defaultSettings = {
19+ apiHost: 'https://api.minimax.io',
20+ model: 'speech-02-hd',
21+ voiceMap: {},
22+ speed: 1.0,
23+ volume: 1.0,
24+ pitch: 1.0,
25+ audioSampleRate: 32000,
26+ bitrate: 128000,
27+ format: 'mp3',
28+ customModels: [],
29+ customVoices: [],
30+ customVoiceId: '',
31+ };
32+
33+ // MiniMax API doesn't provide a method to list user's cloned voices
34+ // so users need to manually input their custom cloned voice IDs
35+ static defaultVoices = [
36+ { name: 'Unrestrained Young Man', voice_id: 'Chinese (Mandarin)_Unrestrained_Young_Man', lang: 'zh-CN', preview_url: null },
37+ ];
38+
39+ // default models (by MiniMax doc)
40+ static defaultModels = [
41+ { id: 'speech-02-hd', name: 'Speech-02-HD (High Quality)' },
42+ { id: 'speech-02-turbo', name: 'Speech-02-Turbo (Fast)' },
43+ { id: 'speech-01', name: 'Speech-01 (Legacy)' },
44+ { id: 'speech-01-240228', name: 'Speech-01-240228 (Legacy)' },
45+ ];
46+
47+ availableModels = [];
48+ availableVoices = [];
49+
50+ get settingsHtml() {
51+ return `
52+ <div class="minimax_tts_settings">
53+ <div class="tts_block justifyCenter">
54+ <div id="api_key_minimax" class="menu_button menu_button_icon manage-api-keys" data-key="api_key_minimax">
55+ <i class="fa-solid fa-key"></i>
56+ <span>Click to set API Key</span>
57+ </div>
58+ <div id="minimax_group_id" class="menu_button menu_button_icon manage-api-keys" data-key="minimax_group_id">
59+ <i class="fa-solid fa-key"></i>
60+ <span>Click to set Group ID</span>
61+ </div>
62+ </div>
63+ <div class="tts_block">
64+ <label for="minimax_tts_api_host">API Host</label>
65+ <select id="minimax_tts_api_host" class="text_pole">
66+ <option value="https://api.minimax.io">Official (api.minimax.io)</option>
67+ <option value="https://api.minimaxi.chat">Global (api.minimaxi.chat)</option>
68+ <option value="https://api.minimax.chat">Mainland China (api.minimax.chat)</option>
69+ </select>
70+ </div>
71+ <div class="tts_block">
72+ <label for="minimax_tts_model">Model</label>
73+ <select id="minimax_tts_model" class="text_pole">
74+ <option value="speech-02-hd">Speech-02-HD (High Quality)</option>
75+ <option value="speech-02-turbo">Speech-02-Turbo (Fast)</option>
76+ <option value="speech-01">Speech-01 (Legacy)</option>
77+ <option value="speech-01-240228">Speech-01-240228 (Legacy)</option>
78+ </select>
79+ </div>
80+ <div class="tts_block">
81+ <input id="minimax_connect" class="menu_button" type="button" value="Connect" />
82+ <input id="minimax_refresh" class="menu_button" type="button" value="Refresh" />
83+ </div>
84+
85+ <div class="tts_block">
86+ <label for="minimax_tts_speed">Speed: <span id="minimax_tts_speed_output"></span></label>
87+ <input id="minimax_tts_speed" type="range" value="${this.defaultSettings.speed}" min="0.5" max="2.0" step="0.1" />
88+ </div>
89+ <div class="tts_block">
90+ <label for="minimax_tts_volume">Volume: <span id="minimax_tts_volume_output"></span></label>
91+ <input id="minimax_tts_volume" type="range" value="${this.defaultSettings.volume}" min="0.1" max="2.0" step="0.1" />
92+ </div>
93+ <div class="tts_block">
94+ <label for="minimax_tts_pitch">Pitch: <span id="minimax_tts_pitch_output"></span></label>
95+ <input id="minimax_tts_pitch" type="range" value="${this.defaultSettings.pitch}" min="0.5" max="2.0" step="0.1" />
96+ </div>
97+ <div class="tts_block">
98+ <label for="minimax_tts_format">Audio Format</label>
99+ <select id="minimax_tts_format" class="text_pole">
100+ <option value="mp3">MP3</option>
101+ <option value="wav">WAV</option>
102+ <option value="flac">FLAC</option>
103+ </select>
104+ </div>
105+
106+ <hr>
107+ <div class="tts_block">
108+ <label for="minimax_tts_custom_voice_id">Custom Voice ID (for 'customVoice' option)</label>
109+ <input id="minimax_tts_custom_voice_id" type="text" class="text_pole" placeholder="Enter custom voice ID from MiniMax platform"/>
110+ </div>
111+
112+ <hr>
113+ <div id="minimax_custom_voice_cloning" class="tts_block flexFlowColumn">
114+ <h4>Custom Voice Management</h4>
115+ <div class="tts_block wide100p">
116+ <input id="minimax_custom_voice_name" type="text" class="text_pole" placeholder="Voice Name"/>
117+ </div>
118+ <div class="tts_block wide100p">
119+ <input id="minimax_custom_voice_id" type="text" class="text_pole" placeholder="Voice ID (from MiniMax platform)"/>
120+ </div>
121+ <div class="tts_block wide100p">
122+ <select id="minimax_custom_voice_lang" class="text_pole">
123+ <option value="auto">Auto Detect</option>
124+ <option value="Chinese">Chinese (中文)</option>
125+ <option value="Chinese,Yue">Chinese, Yue (粤语)</option>
126+ <option value="English">English</option>
127+ <option value="Arabic">Arabic (العربية)</option>
128+ <option value="Russian">Russian (Русский)</option>
129+ <option value="Spanish">Spanish (Español)</option>
130+ <option value="French">French (Français)</option>
131+ <option value="Portuguese">Portuguese (Português)</option>
132+ <option value="German">German (Deutsch)</option>
133+ <option value="Turkish">Turkish (Türkçe)</option>
134+ <option value="Dutch">Dutch (Nederlands)</option>
135+ <option value="Ukrainian">Ukrainian (Українська)</option>
136+ <option value="Vietnamese">Vietnamese (Tiếng Việt)</option>
137+ <option value="Indonesian">Indonesian (Bahasa Indonesia)</option>
138+ <option value="Japanese">Japanese (日本語)</option>
139+ <option value="Italian">Italian (Italiano)</option>
140+ <option value="Korean">Korean (한국어)</option>
141+ <option value="Thai">Thai (ไทย)</option>
142+ <option value="Polish">Polish (Polski)</option>
143+ <option value="Romanian">Romanian (Română)</option>
144+ <option value="Greek">Greek (Ελληνικά)</option>
145+ <option value="Czech">Czech (Čeština)</option>
146+ <option value="Finnish">Finnish (Suomi)</option>
147+ <option value="Hindi">Hindi (हिन्दी)</option>
148+ </select>
149+ </div>
150+ <div class="tts_block">
151+ <input id="minimax_add_custom_voice" class="menu_button" type="button" value="Add Custom Voice">
152+ </div>
153+ <div id="minimax_custom_voices_list" style="margin-top: 10px;"></div>
154+ </div>
155+
156+ <hr>
157+ <div id="minimax_custom_model_management" class="tts_block flexFlowColumn">
158+ <h4>Custom Model Management</h4>
159+ <div class="tts_block wide100p">
160+ <input id="minimax_custom_model_id" type="text" class="text_pole" placeholder="Model ID"/>
161+ </div>
162+ <div class="tts_block wide100p">
163+ <input id="minimax_custom_model_name" type="text" class="text_pole" placeholder="Model Name"/>
164+ </div>
165+ <div class="tts_block">
166+ <input id="minimax_add_custom_model" class="menu_button" type="button" value="Add Custom Model">
167+ </div>
168+ <div id="minimax_custom_models_list" style="margin-top: 10px;"></div>
169+ </div>
170+ </div>
171+ `;
172+ }
173+
174+ constructor() {
175+ this.handler = async function (/** @type {string} */ key) {
176+ if (![SECRET_KEYS.MINIMAX, SECRET_KEYS.MINIMAX_GROUP_ID].includes(key)) return;
177+ $('#api_key_minimax').toggleClass('success', !!secret_state[SECRET_KEYS.MINIMAX]);
178+ $('#minimax_group_id').toggleClass('success', !!secret_state[SECRET_KEYS.MINIMAX_GROUP_ID]);
179+ await this.onRefreshClick();
180+ }.bind(this);
181+ }
182+
183+ dispose() {
184+ [event_types.SECRET_WRITTEN, event_types.SECRET_DELETED, event_types.SECRET_ROTATED].forEach(event => {
185+ eventSource.removeListener(event, this.handler);
186+ });
187+ }
188+
189+ onSettingsChange() {
190+ this.settings.apiHost = $('#minimax_tts_api_host').val();
191+ this.settings.speed = parseFloat($('#minimax_tts_speed').val().toString());
192+ this.settings.volume = parseFloat($('#minimax_tts_volume').val().toString());
193+ this.settings.pitch = parseFloat($('#minimax_tts_pitch').val().toString());
194+ this.settings.model = $('#minimax_tts_model').find(':selected').val();
195+ this.settings.format = $('#minimax_tts_format').find(':selected').val();
196+ this.settings.customVoiceId = $('#minimax_tts_custom_voice_id').val();
197+
198+ $('#minimax_tts_speed_output').text(this.settings.speed.toFixed(1));
199+ $('#minimax_tts_volume_output').text(this.settings.volume.toFixed(1));
200+ $('#minimax_tts_pitch_output').text(this.settings.pitch.toFixed(1));
201+
202+ saveTtsProviderSettings();
203+ }
204+
205+ addCustomModel() {
206+ const modelId = $('#minimax_custom_model_id').val().toString().trim();
207+ const modelName = $('#minimax_custom_model_name').val().toString().trim();
208+
209+ if (!modelId || !modelName) {
210+ toastr.error('Please enter model ID and name');
211+ return;
212+ }
213+
214+ // Check if already exists in custom models
215+ if (this.settings.customModels.find(m => m.id === modelId)) {
216+ toastr.error('Model ID already exists in custom models');
217+ return;
218+ }
219+
220+ // Check if conflicts with default models
221+ if (MiniMaxTtsProvider.defaultModels.find(m => m.id === modelId)) {
222+ toastr.error('Model ID conflicts with default model. Please use a different model ID.');
223+ return;
224+ }
225+
226+ // Check if conflicts with default model names
227+ if (MiniMaxTtsProvider.defaultModels.find(m => m.name === modelName)) {
228+ toastr.error('Model name conflicts with default model. Please use a different model name.');
229+ return;
230+ }
231+
232+ this.settings.customModels.push({ id: modelId, name: modelName });
233+ $('#minimax_custom_model_id').val('');
234+ $('#minimax_custom_model_name').val('');
235+
236+ this.updateCustomModelsDisplay();
237+ this.updateModelSelect(this.getAllModels());
238+ saveTtsProviderSettings();
239+ toastr.success('Model added successfully');
240+ }
241+
242+ removeCustomModel(modelId) {
243+ this.settings.customModels = this.settings.customModels.filter(m => m.id !== modelId);
244+ this.updateCustomModelsDisplay();
245+ this.updateModelSelect(this.getAllModels());
246+ saveTtsProviderSettings();
247+
248+ toastr.success('Model removed successfully');
249+ }
250+
251+ addCustomVoice() {
252+ const voiceName = $('#minimax_custom_voice_name').val().toString().trim();
253+ const voiceId = $('#minimax_custom_voice_id').val().toString().trim();
254+ const voiceLang = $('#minimax_custom_voice_lang').val().toString().trim();
255+
256+ if (!voiceName || !voiceId) {
257+ toastr.error('Please enter voice name and ID');
258+ return;
259+ }
260+
261+ // Check if already exists in custom voices
262+ if (this.settings.customVoices.find(v => v.voice_id === voiceId)) {
263+ toastr.error('Voice ID already exists in custom voices');
264+ return;
265+ }
266+
267+ // Check if conflicts with default voices
268+ if (MiniMaxTtsProvider.defaultVoices.find(v => v.voice_id === voiceId)) {
269+ toastr.error('Voice ID conflicts with default voice. Please use a different voice ID.');
270+ return;
271+ }
272+
273+ // Check if conflicts with default voice names
274+ if (MiniMaxTtsProvider.defaultVoices.find(v => v.name === voiceName)) {
275+ toastr.error('Voice name conflicts with default voice. Please use a different voice name.');
276+ return;
277+ }
278+
279+ // Convert display name to standard language code before saving
280+ const standardLangCode = this.convertDisplayNameToLanguageCode(voiceLang);
281+
282+ this.settings.customVoices.push({
283+ name: voiceName,
284+ voice_id: voiceId,
285+ lang: standardLangCode,
286+ preview_url: null,
287+ });
288+
289+ $('#minimax_custom_voice_name').val('');
290+ $('#minimax_custom_voice_id').val('');
291+ $('#minimax_custom_voice_lang').val('auto');
292+
293+ this.updateCustomVoicesDisplay();
294+ initVoiceMap(); // Update TTS extension voiceMap
295+ saveTtsProviderSettings();
296+ toastr.success('Voice added successfully');
297+ }
298+
299+ // Remove custom voice
300+ removeCustomVoice(voiceId) {
301+ this.settings.customVoices = this.settings.customVoices.filter(v => v.voice_id !== voiceId);
302+ this.updateCustomVoicesDisplay();
303+ initVoiceMap(); // Update TTS extension voiceMap
304+ saveTtsProviderSettings();
305+ toastr.success('Voice removed successfully');
306+ }
307+
308+ // Helper function to escape HTML
309+ escapeHtml(text) {
310+ const div = document.createElement('div');
311+ div.textContent = text;
312+ return div.innerHTML;
313+ }
314+
315+ // Update custom models display
316+ updateCustomModelsDisplay() {
317+ const container = $('#minimax_custom_models_list');
318+ container.empty();
319+
320+ if (this.settings.customModels.length === 0) {
321+ container.append('<div class="minimax-empty-list">No custom models added</div>');
322+ return;
323+ }
324+
325+ this.settings.customModels.forEach(model => {
326+ const modelDiv = $('<div></div>').addClass('minimax-custom-item');
327+
328+ const modelInfo = $('<div></div>').addClass('minimax-custom-item-info');
329+ const modelName = $('<div></div>').addClass('minimax-custom-item-name').text(model.name);
330+ const modelId = $('<div></div>').addClass('minimax-custom-item-details').text(`(${model.id})`);
331+ modelInfo.append(modelName).append(modelId);
332+
333+ const removeBtn = $('<button></button>')
334+ .addClass('menu_button minimax-custom-item-remove')
335+ .text('Remove')
336+ .on('click', () => {
337+ try {
338+ this.removeCustomModel(model.id);
339+ } catch (error) {
340+ console.error('MiniMax TTS: Error removing custom model:', error);
341+ toastr.error(`Failed to remove custom model: ${error.message}`);
342+ }
343+ });
344+
345+ modelDiv.append(modelInfo).append(removeBtn);
346+ container.append(modelDiv);
347+ });
348+ }
349+
350+ // Update custom voices display
351+ updateCustomVoicesDisplay() {
352+ const container = $('#minimax_custom_voices_list');
353+ container.empty();
354+
355+ if (this.settings.customVoices.length === 0) {
356+ container.append('<div class="minimax-empty-list">No custom voices added</div>');
357+ return;
358+ }
359+
360+ this.settings.customVoices.forEach(voice => {
361+ const voiceDiv = $('<div></div>').addClass('minimax-custom-item');
362+
363+ const voiceInfo = $('<div></div>').addClass('minimax-custom-item-info');
364+ const voiceName = $('<div></div>').addClass('minimax-custom-item-name').text(voice.name);
365+ const voiceDetails = $('<div></div>').addClass('minimax-custom-item-details').text(`(${voice.voice_id}) - ${voice.lang}`);
366+ voiceInfo.append(voiceName).append(voiceDetails);
367+
368+ const removeBtn = $('<button></button>')
369+ .addClass('menu_button minimax-custom-item-remove')
370+ .text('Remove')
371+ .on('click', () => {
372+ try {
373+ this.removeCustomVoice(voice.voice_id);
374+ } catch (error) {
375+ console.error('MiniMax TTS: Error removing custom voice:', error);
376+ toastr.error(`Failed to remove custom voice: ${error.message}`);
377+ }
378+ });
379+
380+ voiceDiv.append(voiceInfo).append(removeBtn);
381+ container.append(voiceDiv);
382+ });
383+ }
384+
385+ // Get all models (default + custom)
386+ getAllModels() {
387+ return [...MiniMaxTtsProvider.defaultModels, ...this.settings.customModels];
388+ }
389+
390+ // Get all voices (default + custom)
391+ getAllVoices() {
392+ return [...MiniMaxTtsProvider.defaultVoices, ...this.settings.customVoices];
393+ }
394+
395+ /**
396+ * Convert display names to standard language codes
397+ * @param {string} displayName Language display name
398+ * @returns {string} Standard language code
399+ */
400+ convertDisplayNameToLanguageCode(displayName) {
401+ const displayNameToCode = {
402+ 'Chinese': 'zh-CN',
403+ 'Chinese,Yue': 'zh-TW',
404+ 'English': 'en-US',
405+ 'Japanese': 'ja-JP',
406+ 'Korean': 'ko-KR',
407+ 'French': 'fr-FR',
408+ 'German': 'de-DE',
409+ 'Spanish': 'es-ES',
410+ 'Portuguese': 'pt-BR',
411+ 'Italian': 'it-IT',
412+ 'Arabic': 'ar-SA',
413+ 'Russian': 'ru-RU',
414+ 'Turkish': 'tr-TR',
415+ 'Dutch': 'nl-NL',
416+ 'Ukrainian': 'uk-UA',
417+ 'Vietnamese': 'vi-VN',
418+ 'Indonesian': 'id-ID',
419+ 'Thai': 'th-TH',
420+ 'Polish': 'pl-PL',
421+ 'Romanian': 'ro-RO',
422+ 'Greek': 'el-GR',
423+ 'Czech': 'cs-CZ',
424+ 'Finnish': 'fi-FI',
425+ 'Hindi': 'hi-IN',
426+ };
427+
428+ return displayNameToCode[displayName] || displayName;
429+ }
430+
431+ updateModelSelect(models) {
432+ const modelSelect = $('#minimax_tts_model');
433+ const currentValue = modelSelect.val();
434+
435+ // Clear existing options
436+ modelSelect.empty();
437+
438+ // Add all models
439+ models.forEach(model => {
440+ const option = $('<option></option>');
441+ option.val(model.id);
442+ option.text(model.name);
443+ modelSelect.append(option);
444+ });
445+
446+ // Restore previous selection if it still exists
447+ if (currentValue && models.find(m => m.id === currentValue)) {
448+ modelSelect.val(currentValue);
449+ }
450+ }
451+
452+ async loadSettings(settings) {
453+ // Populate Provider UI given input settings
454+ if (Object.keys(settings).length === 0) {
455+ console.info('Using default MiniMax TTS Provider settings');
456+ }
457+
458+ // Only accept keys defined in defaultSettings
459+ this.settings = { ...this.defaultSettings };
460+
461+ for (const key in settings) {
462+ if (key in this.settings) {
463+ this.settings[key] = settings[key];
464+ } else {
465+ console.warn(`Invalid setting passed to MiniMax TTS Provider: ${key}`);
466+ }
467+ }
468+
469+ // Ensure custom configuration arrays exist
470+ if (!this.settings.customModels) this.settings.customModels = [];
471+ if (!this.settings.customVoices) this.settings.customVoices = [];
472+
473+ $('#minimax_tts_api_host').val(this.settings.apiHost || 'https://api.minimax.io');
474+ $('#minimax_tts_model').val(this.settings.model);
475+ $('#minimax_tts_speed').val(this.settings.speed);
476+ $('#minimax_tts_volume').val(this.settings.volume);
477+ $('#minimax_tts_pitch').val(this.settings.pitch);
478+ $('#minimax_tts_format').val(this.settings.format);
479+ $('#minimax_tts_custom_voice_id').val(this.settings.customVoiceId);
480+
481+ $('#minimax_connect').on('click', () => {
482+ try {
483+ this.onConnectClick();
484+ } catch (error) {
485+ console.error('MiniMax TTS: Error in connect click handler:', error);
486+ toastr.error(`Connection failed: ${error.message}`);
487+ }
488+ });
489+ $('#minimax_refresh').on('click', () => {
490+ try {
491+ this.onRefreshClick();
492+ } catch (error) {
493+ console.error('MiniMax TTS: Error in refresh click handler:', error);
494+ toastr.error(`Refresh failed: ${error.message}`);
495+ }
496+ });
497+ $('#minimax_tts_api_host').on('change', this.onSettingsChange.bind(this));
498+ $('#minimax_tts_speed').on('input', this.onSettingsChange.bind(this));
499+ $('#minimax_tts_volume').on('input', this.onSettingsChange.bind(this));
500+ $('#minimax_tts_pitch').on('input', this.onSettingsChange.bind(this));
501+ $('#minimax_tts_model').on('change', this.onSettingsChange.bind(this));
502+ $('#minimax_tts_format').on('change', this.onSettingsChange.bind(this));
503+ $('#minimax_tts_custom_voice_id').on('input', this.onSettingsChange.bind(this));
504+
505+ // Custom model and voice event listeners
506+ $('#minimax_add_custom_model').on('click', () => {
507+ try {
508+ this.addCustomModel();
509+ } catch (error) {
510+ console.error('MiniMax TTS: Error adding custom model:', error);
511+ toastr.error(`Failed to add custom model: ${error.message}`);
512+ }
513+ });
514+ $('#minimax_add_custom_voice').on('click', () => {
515+ try {
516+ this.addCustomVoice();
517+ } catch (error) {
518+ console.error('MiniMax TTS: Error adding custom voice:', error);
519+ toastr.error(`Failed to add custom voice: ${error.message}`);
520+ }
521+ });
522+
523+ // Keyboard event listeners
524+ const ENTER_KEY = 13;
525+ $('#minimax_custom_model_id, #minimax_custom_model_name').on('keypress', (e) => {
526+ if (e.which === ENTER_KEY) {
527+ try {
528+ this.addCustomModel();
529+ } catch (error) {
530+ console.error('MiniMax TTS: Error adding custom model via keyboard:', error);
531+ toastr.error(`Failed to add custom model: ${error.message}`);
532+ }
533+ }
534+ });
535+
536+ $('#minimax_custom_voice_name, #minimax_custom_voice_id').on('keypress', (e) => {
537+ if (e.which === ENTER_KEY) {
538+ try {
539+ this.addCustomVoice();
540+ } catch (error) {
541+ console.error('MiniMax TTS: Error adding custom voice via keyboard:', error);
542+ toastr.error(`Failed to add custom voice: ${error.message}`);
543+ }
544+ }
545+ });
546+
547+ $('#minimax_tts_speed_output').text(this.settings.speed.toFixed(1));
548+ $('#minimax_tts_volume_output').text(this.settings.volume.toFixed(1));
549+ $('#minimax_tts_pitch_output').text(this.settings.pitch.toFixed(1));
550+
551+ // Initialize custom configuration display
552+ this.updateCustomModelsDisplay();
553+ this.updateCustomVoicesDisplay();
554+
555+ // Update model selector to include custom models
556+ this.updateModelSelect(this.getAllModels());
557+
558+ // Initialize voice map for character voice assignment
559+ try {
560+ await initVoiceMap();
561+ } catch (error) {
562+ console.debug('MiniMax: Voice map initialization failed, but continuing');
563+ }
564+
565+ $('#api_key_minimax').toggleClass('success', !!secret_state[SECRET_KEYS.MINIMAX]);
566+ $('#minimax_group_id').toggleClass('success', !!secret_state[SECRET_KEYS.MINIMAX_GROUP_ID]);
567+ [event_types.SECRET_WRITTEN, event_types.SECRET_DELETED, event_types.SECRET_ROTATED].forEach(event => {
568+ eventSource.on(event, this.handler);
569+ });
570+
571+ // Only check ready status when API credentials are available
572+ if (secret_state[SECRET_KEYS.MINIMAX] && secret_state[SECRET_KEYS.MINIMAX_GROUP_ID]) {
573+ try {
574+ await this.checkReady();
575+ console.debug('MiniMax TTS: Settings loaded and ready');
576+ } catch (error) {
577+ console.debug('MiniMax TTS: Settings loaded, but not ready:', error);
578+ }
579+ } else {
580+ console.debug('MiniMax TTS: Settings loaded, waiting for API credentials');
581+ }
582+ }
583+
584+ // Perform a simple readiness check
585+ async checkReady() {
586+ if (!secret_state[SECRET_KEYS.MINIMAX] || !secret_state[SECRET_KEYS.MINIMAX_GROUP_ID]) {
587+ const error = new Error('API Key and Group ID are required');
588+ console.error('MiniMax TTS checkReady error:', error.message);
589+ throw error;
590+ }
591+ // Try to fetch available models and voices, but don't block connection on failure
592+ try {
593+ await this.updateModelsAndVoices();
594+ } catch (error) {
595+ console.warn('MiniMax TTS: Failed to fetch models/voices during ready check, will use all available:', error);
596+ // Even if API call fails, set all available values to ensure basic functionality
597+ this.availableModels = this.getAllModels();
598+ this.availableVoices = this.getAllVoices();
599+ }
600+
601+ // Ensure at least voices are available
602+ if (!this.availableVoices || this.availableVoices.length === 0) {
603+ this.availableVoices = this.getAllVoices();
604+ }
605+ }
606+
607+ async onRefreshClick() {
608+ try {
609+ await this.updateModelsAndVoices();
610+ await initVoiceMap(); // Update voice map after refresh
611+ toastr.success('MiniMax TTS: Models and voices refreshed successfully');
612+ } catch (error) {
613+ toastr.error(`MiniMax TTS: Failed to refresh - ${error.message}`);
614+ }
615+ }
616+
617+ async onConnectClick() {
618+ try {
619+ await this.checkReady();
620+ await initVoiceMap(); // Update voice map after connection
621+ toastr.success('MiniMax TTS: Connected successfully');
622+ saveTtsProviderSettings();
623+ } catch (error) {
624+ toastr.error(`MiniMax TTS: ${error.message}`);
625+ }
626+ }
627+
628+ async getVoice(voiceName) {
629+ if (!voiceName) {
630+ const error = new Error('TTS Voice name not provided');
631+ console.error('MiniMax TTS getVoice error:', error.message);
632+ throw error;
633+ }
634+
635+ // If no available voices, try to fetch them
636+ if (!this.availableVoices || this.availableVoices.length === 0) {
637+ this.availableVoices = await this.fetchTtsVoiceObjects();
638+ }
639+
640+ // Ensure at least voices are available
641+ if (!this.availableVoices || this.availableVoices.length === 0) {
642+ this.availableVoices = this.getAllVoices();
643+ }
644+
645+ const voice = this.availableVoices.find(voice =>
646+ voice.voice_id === voiceName || voice.name === voiceName,
647+ );
648+
649+ if (!voice) {
650+ const error = new Error(`TTS Voice not found: ${voiceName}`);
651+ console.error('MiniMax TTS getVoice error:', error.message);
652+ throw error;
653+ }
654+
655+ return voice;
656+ }
657+
658+ async generateTts(text, voiceId) {
659+ // If voiceId is 'customVoice', use the custom voice ID from settings
660+ if (voiceId === 'customVoice') {
661+ const customVoiceId = this.settings.customVoiceId;
662+ if (!customVoiceId || customVoiceId.trim() === '') {
663+ const error = new Error('Please enter custom voice ID in settings first');
664+ console.error('MiniMax TTS generateTts error:', error.message);
665+ throw error;
666+ }
667+ voiceId = customVoiceId.trim();
668+ }
669+
670+ // Get the voice object to determine language
671+ let language = null;
672+ try {
673+ const voice = await this.getVoice(voiceId);
674+ if (voice && voice.lang) {
675+ language = this.mapLanguageToMiniMaxFormat(voice.lang);
676+ console.debug(`MiniMax TTS: Using voice language ${voice.lang}, API language: ${language}`);
677+ }
678+ } catch (error) {
679+ console.debug('MiniMax TTS: Could not determine voice language, using default');
680+ }
681+
682+ return await this.fetchTtsGeneration(text, voiceId, language);
683+ }
684+
685+ async fetchTtsVoiceObjects() {
686+ try {
687+ if (!secret_state[SECRET_KEYS.MINIMAX] || !secret_state[SECRET_KEYS.MINIMAX_GROUP_ID]) {
688+ console.warn('MiniMax TTS: API Key and Group ID required for fetching voices');
689+ console.warn('Using all available voices (default + custom). Please check your API credentials');
690+ return this.getAllVoices();
691+ }
692+
693+ // MiniMax API doesn't provide a voices listing endpoint
694+ // Using all available voices (default + custom)
695+ console.info('MiniMax TTS: Using all available voices (default + custom)');
696+ return this.getAllVoices();
697+ } catch (error) {
698+ console.error('Error fetching MiniMax voices:', error);
699+ console.warn('Using all available voices (default + custom). Please check your API credentials');
700+ return this.getAllVoices();
701+ }
702+ }
703+
704+ async fetchTtsModels() {
705+ // MiniMax API doesn't provide a models listing endpoint
706+ // Using all available models (default + custom)
707+ console.info('MiniMax TTS: Using all available models (default + custom)');
708+ this.availableModels = this.getAllModels();
709+ return this.getAllModels();
710+ }
711+
712+ async updateModelsAndVoices() {
713+ try {
714+ // Get models list
715+ this.availableModels = await this.fetchTtsModels();
716+ console.info(`MiniMax TTS: Loaded ${this.availableModels.length} models`);
717+
718+ // Get voices list (now fetched from API)
719+ this.availableVoices = await this.fetchTtsVoiceObjects();
720+ console.info(`MiniMax TTS: Loaded ${this.availableVoices.length} voices`);
721+
722+ // Update model dropdown
723+ this.updateModelSelect(this.availableModels);
724+
725+ return {
726+ models: this.availableModels,
727+ voices: this.availableVoices,
728+ };
729+ } catch (error) {
730+ console.error('MiniMax TTS: Failed to update models and voices:', error);
731+ // Set all available values to ensure basic functionality
732+ this.availableModels = this.getAllModels();
733+ this.availableVoices = this.getAllVoices();
734+ throw error;
735+ }
736+ }
737+
738+ // Get correct MIME type
739+ getAudioMimeType(format) {
740+ const mimeTypes = {
741+ 'mp3': 'audio/mpeg',
742+ 'wav': 'audio/wav',
743+ 'pcm': 'audio/pcm',
744+ 'flac': 'audio/flac',
745+ 'aac': 'audio/aac',
746+ };
747+ return mimeTypes[format] || 'audio/mpeg';
748+ }
749+
750+ async fetchTtsGeneration(inputText, voiceId, language = null) {
751+ console.info(`Generating new MiniMax TTS for voice_id ${voiceId}`);
752+
753+ if (!secret_state[SECRET_KEYS.MINIMAX] || !secret_state[SECRET_KEYS.MINIMAX_GROUP_ID]) {
754+ const error = new Error('API Key and Group ID are required');
755+ console.error('MiniMax TTS fetchTtsGeneration error:', error.message);
756+ throw error;
757+ }
758+
759+ const requestBody = {
760+ text: inputText,
761+ voiceId: voiceId,
762+ apiHost: this.settings.apiHost,
763+ model: this.settings.model || 'speech-02-hd',
764+ speed: Number(this.settings.speed) || 1.0,
765+ volume: Number(this.settings.volume) || 1.0,
766+ pitch: Number(this.settings.pitch) || 1.0,
767+ audioSampleRate: Number(this.settings.audioSampleRate) || 32000,
768+ bitrate: Number(this.settings.bitrate) || 128000,
769+ format: this.settings.format || 'mp3',
770+ language: language,
771+ };
772+
773+ console.debug('MiniMax TTS Request:', {
774+ body: { ...requestBody, voiceId: '[REDACTED]' },
775+ });
776+
777+ try {
778+ const response = await fetch('/api/minimax/generate-voice', {
779+ method: 'POST',
780+ headers: getRequestHeaders(),
781+ body: JSON.stringify(requestBody),
782+ });
783+
784+ if (!response.ok) {
785+ let errorMessage = `HTTP ${response.status}`;
786+
787+ try {
788+ // Try to parse JSON error response from backend
789+ const errorData = await response.json();
790+ console.error('MiniMax TTS backend error:', errorData);
791+ errorMessage = errorData.error || errorMessage;
792+ } catch (jsonError) {
793+ // If not JSON, try to read text
794+ try {
795+ const errorText = await response.text();
796+ console.error('MiniMax TTS backend error (Text):', errorText);
797+ errorMessage = errorText || errorMessage;
798+ } catch (textError) {
799+ console.error('MiniMax TTS: Failed to read error response:', textError);
800+ }
801+ }
802+
803+ toastr.error(`${errorMessage}`, 'MiniMax TTS Generation Failed');
804+ const error = new Error(errorMessage);
805+ console.error('MiniMax TTS fetchTtsGeneration error:', error.message);
806+ throw error;
807+ }
808+
809+ // Backend handles all the complex processing and returns audio data directly
810+ console.debug('MiniMax TTS: Audio response received from backend');
811+ return response;
812+
813+ } catch (error) {
814+ console.error('Error in MiniMax TTS generation:', error);
815+ throw error;
816+ }
817+ }
818+
819+ /**
820+ * Map language codes to MiniMax API supported language format
821+ * @param {string} lang Language code or display name
822+ * @returns {string} MiniMax API language format
823+ */
824+ mapLanguageToMiniMaxFormat(lang) {
825+ // Convert display name to language code if needed
826+ const languageCode = this.convertDisplayNameToLanguageCode(lang);
827+
828+ // Then map language codes to MiniMax API format
829+ const languageMap = {
830+ 'zh-CN': 'zh_CN',
831+ 'zh-TW': 'zh_TW',
832+ 'en-US': 'en_US',
833+ 'en-GB': 'en_GB',
834+ 'en-AU': 'en_AU',
835+ 'en-IN': 'en_IN',
836+ 'ja-JP': 'ja_JP',
837+ 'ko-KR': 'ko_KR',
838+ 'fr-FR': 'fr_FR',
839+ 'de-DE': 'de_DE',
840+ 'es-ES': 'es_ES',
841+ 'pt-BR': 'pt_BR',
842+ 'it-IT': 'it_IT',
843+ 'ar-SA': 'ar_SA',
844+ 'ru-RU': 'ru_RU',
845+ 'tr-TR': 'tr_TR',
846+ 'nl-NL': 'nl_NL',
847+ 'uk-UA': 'uk_UA',
848+ 'vi-VN': 'vi_VN',
849+ 'id-ID': 'id_ID',
850+ 'th-TH': 'th_TH',
851+ 'pl-PL': 'pl_PL',
852+ 'ro-RO': 'ro_RO',
853+ 'el-GR': 'el_GR',
854+ 'cs-CZ': 'cs_CZ',
855+ 'fi-FI': 'fi_FI',
856+ 'hi-IN': 'hi_IN',
857+ };
858+
859+ // Return mapped language or default to auto
860+ return languageMap[languageCode] || 'auto';
861+ }
862+
863+ /**
864+ * Preview TTS for a given voice ID.
865+ * @param {string} voiceId Voice ID
866+ */
867+ async previewTtsVoice(voiceId) {
868+ this.audioElement.pause();
869+ this.audioElement.currentTime = 0;
870+
871+ try {
872+ const voice = await this.getVoice(voiceId);
873+ // Get preview text based on voice language, defaulting to en-US
874+ const previewLang = voice.lang || 'en-US';
875+ const text = getPreviewString(previewLang);
876+
877+ // Map the language to MiniMax API format for the request
878+ const apiLang = this.mapLanguageToMiniMaxFormat(previewLang);
879+ console.debug(`MiniMax TTS: Using preview language ${previewLang}, API language: ${apiLang}`);
880+
881+ const response = await this.fetchTtsGeneration(text, voiceId, apiLang);
882+
883+ if (!response.ok) {
884+ const errorText = await response.text();
885+ const error = new Error(`HTTP ${response.status}: ${errorText}`);
886+ console.error('MiniMax TTS previewTtsVoice error:', error.message);
887+ throw error;
888+ }
889+
890+ const audio = await response.blob();
891+ console.debug(`MiniMax TTS: Audio blob size: ${audio.size}, type: ${audio.type}`);
892+
893+ // Use the same method as other TTS providers - convert to base64 data URL
894+ const srcUrl = await getBase64Async(audio);
895+ console.debug('MiniMax TTS: Base64 data URL created');
896+
897+ // Clean up previous event listener to prevent memory leaks
898+ this.audioElement.onended = null;
899+ this.audioElement.onerror = null;
900+
901+ this.audioElement.src = srcUrl;
902+ this.audioElement.volume = Math.min(this.settings.volume || 1.0, 1.0); // HTML audio element max is 1.0
903+
904+ // Add error handler for audio element
905+ this.audioElement.onerror = (e) => {
906+ console.error('MiniMax TTS: Audio element error:', e);
907+ console.error('MiniMax TTS: Audio element error details:', {
908+ error: this.audioElement.error,
909+ networkState: this.audioElement.networkState,
910+ readyState: this.audioElement.readyState,
911+ src: this.audioElement.src,
912+ });
913+
914+ toastr.error('Audio playback failed. The audio format may not be supported by your browser.');
915+ };
916+
917+ try {
918+ await this.audioElement.play();
919+ console.debug('MiniMax TTS: Audio playback started successfully');
920+ } catch (playError) {
921+ console.error('MiniMax TTS: Play error:', playError);
922+ throw new Error(`Audio playback failed: ${playError.message}`);
923+ }
924+
925+ this.audioElement.onended = () => {
926+ this.audioElement.onended = null;
927+ this.audioElement.onerror = null;
928+ };
929+
930+ } catch (error) {
931+ console.error('MiniMax TTS Preview Error:', error);
932+ toastr.error(`Could not generate preview: ${error.message}`);
933+ }
934+ }
935+}
public/scripts/extensions/tts/style.css+2 -0
@@ -125,3 +125,5 @@
125125#at-status_info {
126126 color: lightgreen;
127127}
128+
129+@import './css/minimax-tts.css';
public/scripts/secrets.js+4 -0
@@ -62,6 +62,8 @@ export const SECRET_KEYS = {
6262 FALAI: 'api_key_falai',
6363 XAI: 'api_key_xai',
6464 VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json',
65+ MINIMAX: 'api_key_minimax',
66+ MINIMAX_GROUP_ID: 'minimax_group_id',
6567};
6668
6769const FRIENDLY_NAMES = {
@@ -112,6 +114,8 @@ const FRIENDLY_NAMES = {
112114 [SECRET_KEYS.LINGVA_URL]: 'Lingva Endpoint (e.g. https://lingva.ml/api/v1)',
113115 [SECRET_KEYS.ONERING_URL]: 'OneRingTranslator Endpoint (e.g. http://127.0.0.1:4990/translate)',
114116 [SECRET_KEYS.DEEPLX_URL]: 'DeepLX Endpoint (e.g. http://127.0.0.1:1188/translate)',
117+ [SECRET_KEYS.MINIMAX]: 'MiniMax TTS',
118+ [SECRET_KEYS.MINIMAX_GROUP_ID]: 'MiniMax Group ID',
115119};
116120
117121const INPUT_MAP = {
src/endpoints/minimax.js+230 -0
@@ -0,0 +1,230 @@
1+import express from 'express';
2+import fetch from 'node-fetch';
3+import { readSecret, SECRET_KEYS } from './secrets.js';
4+
5+export const router = express.Router();
6+
7+// Audio format MIME type mapping
8+const getAudioMimeType = (format) => {
9+ const mimeTypes = {
10+ 'mp3': 'audio/mpeg',
11+ 'wav': 'audio/wav',
12+ 'pcm': 'audio/pcm',
13+ 'flac': 'audio/flac',
14+ 'aac': 'audio/aac',
15+ };
16+ return mimeTypes[format] || 'audio/mpeg';
17+};
18+
19+router.post('/generate-voice', async (request, response) => {
20+ try {
21+ const {
22+ text,
23+ voiceId,
24+ apiHost = 'https://api.minimax.io',
25+ model = 'speech-02-hd',
26+ speed = 1.0,
27+ volume = 1.0,
28+ pitch = 1.0,
29+ audioSampleRate = 32000,
30+ bitrate = 128000,
31+ format = 'mp3',
32+ language,
33+ } = request.body;
34+
35+ const apiKey = readSecret(request.user.directories, SECRET_KEYS.MINIMAX);
36+ const groupId = readSecret(request.user.directories, SECRET_KEYS.MINIMAX_GROUP_ID);
37+
38+ // Validate required parameters
39+ if (!text || !voiceId || !apiKey || !groupId) {
40+ console.warn('MiniMax TTS: Missing required parameters');
41+ return response.status(400).json({ error: 'Missing required parameters: text, voiceId, apiKey, and groupId are required' });
42+ }
43+
44+ const requestBody = {
45+ model: model,
46+ text: text,
47+ stream: false,
48+ voice_setting: {
49+ voice_id: voiceId,
50+ speed: Number(speed),
51+ vol: Number(volume),
52+ pitch: Number(pitch),
53+ },
54+ audio_setting: {
55+ sample_rate: Number(audioSampleRate),
56+ bitrate: Number(bitrate),
57+ format: format,
58+ channel: 1,
59+ },
60+ };
61+
62+ // Add language parameter if provided
63+ if (language) {
64+ requestBody.lang = language;
65+ }
66+
67+ const apiUrl = `${apiHost}/v1/t2a_v2?GroupId=${groupId}`;
68+
69+ console.debug('MiniMax TTS Request:', {
70+ url: apiUrl,
71+ body: { ...requestBody, voice_setting: { ...requestBody.voice_setting, voice_id: '[REDACTED]' } },
72+ });
73+
74+ const apiResponse = await fetch(apiUrl, {
75+ method: 'POST',
76+ headers: {
77+ 'Authorization': `Bearer ${apiKey}`,
78+ 'Content-Type': 'application/json',
79+ 'MM-API-Source': 'SillyTavern-TTS',
80+ },
81+ body: JSON.stringify(requestBody),
82+ });
83+
84+ if (!apiResponse.ok) {
85+ let errorMessage = `HTTP ${apiResponse.status}`;
86+
87+ try {
88+ // Try to parse JSON error response
89+ /** @type {any} */
90+ const errorData = await apiResponse.json();
91+ console.error('MiniMax TTS API error (JSON):', errorData);
92+
93+ // Check for MiniMax specific error format
94+ const baseResp = errorData?.base_resp;
95+ if (baseResp && baseResp.status_code !== 0) {
96+ if (baseResp.status_code === 1004) {
97+ errorMessage = 'Authentication failed - Please check your API key and API host';
98+ } else {
99+ errorMessage = `API Error: ${baseResp.status_msg}`;
100+ }
101+ } else {
102+ errorMessage = errorData.error?.message || errorData.message || errorData.detail || `HTTP ${apiResponse.status}`;
103+ }
104+ } catch (jsonError) {
105+ // If not JSON, try to read text
106+ try {
107+ const errorText = await apiResponse.text();
108+ console.error('MiniMax TTS API error (Text):', errorText);
109+ if (errorText && errorText.length > 500) {
110+ errorMessage = `HTTP ${apiResponse.status}: Response too large (${errorText.length} characters)`;
111+ } else {
112+ errorMessage = errorText || `HTTP ${apiResponse.status}`;
113+ }
114+ } catch (textError) {
115+ console.error('MiniMax TTS: Failed to read error response:', textError);
116+ errorMessage = `HTTP ${apiResponse.status}: Unable to read error details`;
117+ }
118+ }
119+
120+ console.error('MiniMax TTS API request failed:', errorMessage);
121+ return response.status(500).json({ error: errorMessage });
122+ }
123+
124+ // Parse the response
125+ /** @type {any} */
126+ let responseData;
127+ try {
128+ responseData = await apiResponse.json();
129+ console.debug('MiniMax TTS Response received');
130+ } catch (jsonError) {
131+ console.error('MiniMax TTS: Failed to parse response as JSON:', jsonError);
132+ return response.status(500).json({ error: 'Invalid response format from MiniMax API' });
133+ }
134+
135+ // Check for API error codes in response data
136+ const baseResp = responseData?.base_resp;
137+ if (baseResp && baseResp.status_code !== 0) {
138+ let errorMessage;
139+ if (baseResp.status_code === 1004) {
140+ errorMessage = 'Authentication failed - Please check your API key and API host';
141+ } else {
142+ errorMessage = `API Error: ${baseResp.status_msg}`;
143+ }
144+ console.error('MiniMax TTS API error:', baseResp);
145+ return response.status(500).json({ error: errorMessage });
146+ }
147+
148+ // Process the audio data
149+ if (responseData.data && responseData.data.audio) {
150+ // Process hex-encoded audio data
151+ const hexAudio = responseData.data.audio;
152+
153+ if (!hexAudio || typeof hexAudio !== 'string') {
154+ console.error('MiniMax TTS: Invalid audio data format');
155+ return response.status(500).json({ error: 'Invalid audio data format' });
156+ }
157+
158+ // Remove possible prefix and spaces
159+ const cleanHex = hexAudio.replace(/^0x/, '').replace(/\s/g, '');
160+
161+ // Validate hex string format
162+ if (!/^[0-9a-fA-F]*$/.test(cleanHex)) {
163+ console.error('MiniMax TTS: Invalid hex string format');
164+ return response.status(500).json({ error: 'Invalid audio data format' });
165+ }
166+
167+ // Ensure hex string length is even
168+ const paddedHex = cleanHex.length % 2 === 0 ? cleanHex : '0' + cleanHex;
169+
170+ try {
171+ // Convert hex string to byte array
172+ const hexMatches = paddedHex.match(/.{1,2}/g);
173+ if (!hexMatches) {
174+ console.error('MiniMax TTS: Failed to parse hex string');
175+ return response.status(500).json({ error: 'Invalid hex string format' });
176+ }
177+ const audioBytes = new Uint8Array(hexMatches.map(byte => parseInt(byte, 16)));
178+
179+ if (audioBytes.length === 0) {
180+ console.error('MiniMax TTS: Audio conversion resulted in empty array');
181+ return response.status(500).json({ error: 'Audio data conversion failed' });
182+ }
183+
184+ console.debug(`MiniMax TTS: Converted ${paddedHex.length} hex characters to ${audioBytes.length} bytes`);
185+
186+ // Set appropriate headers and send audio data
187+ const mimeType = getAudioMimeType(format);
188+ response.setHeader('Content-Type', mimeType);
189+ response.setHeader('Content-Length', audioBytes.length);
190+
191+ return response.send(Buffer.from(audioBytes));
192+
193+ } catch (conversionError) {
194+ console.error('MiniMax TTS: Audio conversion error:', conversionError);
195+ return response.status(500).json({ error: `Audio data conversion failed: ${conversionError.message}` });
196+ }
197+ } else if (responseData.data && responseData.data.url) {
198+ // Handle URL-based audio response
199+ console.debug('MiniMax TTS: Received audio URL:', responseData.data.url);
200+
201+ try {
202+ const audioResponse = await fetch(responseData.data.url);
203+ if (!audioResponse.ok) {
204+ console.error('MiniMax TTS: Failed to fetch audio from URL:', audioResponse.status);
205+ return response.status(500).json({ error: `Failed to fetch audio from URL: ${audioResponse.status}` });
206+ }
207+
208+ const audioBuffer = await audioResponse.arrayBuffer();
209+ const mimeType = getAudioMimeType(format);
210+
211+ response.setHeader('Content-Type', mimeType);
212+ response.setHeader('Content-Length', audioBuffer.byteLength);
213+
214+ return response.send(Buffer.from(audioBuffer));
215+ } catch (urlError) {
216+ console.error('MiniMax TTS: Error fetching audio from URL:', urlError);
217+ return response.status(500).json({ error: `Failed to fetch audio: ${urlError.message}` });
218+ }
219+ } else {
220+ // Handle error response
221+ const errorMessage = responseData.base_resp?.status_msg || responseData.error?.message || 'Unknown error';
222+ console.error('MiniMax TTS: No valid audio data in response:', responseData);
223+ return response.status(500).json({ error: `API Error: ${errorMessage}` });
224+ }
225+
226+ } catch (error) {
227+ console.error('MiniMax TTS generation failed:', error);
228+ return response.status(500).json({ error: 'Internal server error' });
229+ }
230+});
src/endpoints/secrets.js+2 -0
@@ -55,6 +55,8 @@ export const SECRET_KEYS = {
5555 AIMLAPI: 'api_key_aimlapi',
5656 XAI: 'api_key_xai',
5757 VERTEXAI_SERVICE_ACCOUNT: 'vertexai_service_account_json',
58+ MINIMAX: 'api_key_minimax',
59+ MINIMAX_GROUP_ID: 'minimax_group_id',
5860};
5961
6062/**
src/server-startup.js+2 -0
@@ -45,6 +45,7 @@ import { router as koboldRouter } from './endpoints/backends/kobold.js';
4545import { router as textCompletionsRouter } from './endpoints/backends/text-completions.js';
4646import { router as speechRouter } from './endpoints/speech.js';
4747import { router as azureRouter } from './endpoints/azure.js';
48+import { router as minimaxRouter } from './endpoints/minimax.js';
4849import { router as dataMaidRouter } from './endpoints/data-maid.js';
4950
5051/**
@@ -172,6 +173,7 @@ export function setupPrivateEndpoints(app) {
172173 app.use('/api/backends/chat-completions', chatCompletionsRouter);
173174 app.use('/api/speech', speechRouter);
174175 app.use('/api/azure', azureRouter);
176+ app.use('/api/minimax', minimaxRouter);
175177 app.use('/api/data-maid', dataMaidRouter);
176178}
177179