Blame Raw
Cohee · e3f41666 · · 961 lines (40.6 KB)
1 contributor
1import { getPreviewString, initVoiceMap, saveTtsProviderSettings } from './index.js';
2import { event_types, eventSource, getRequestHeaders } from '../../../script.js';
3import { SECRET_KEYS, secret_state } from '../../secrets.js';
4import { getBase64Async } from '../../utils.js';
5
6export { MiniMaxTtsProvider };
7
8class 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: { default: 1.0, min: 0.5, max: 2.0, step: 0.1 },
23 volume: { default: 1.0, min: 0.0, max: 10.0, step: 0.1 },
24 pitch: { default: 0, min: -12, max: 12, step: 1 },
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.default}" min="${this.defaultSettings.speed.min}" max="${this.defaultSettings.speed.max}" step="${this.defaultSettings.speed.step}" />
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.default}" min="${this.defaultSettings.volume.min}" max="${this.defaultSettings.volume.max}" step="${this.defaultSettings.volume.step}" />
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.default}" min="${this.defaultSettings.pitch.min}" max="${this.defaultSettings.pitch.max}" step="${this.defaultSettings.pitch.step}" />
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 = parseInt($('#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);
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 // Flatten the settings fields with default/min/max definitions so the actual values are used
462 this.settings = Object.fromEntries(
463 Object.entries(this.defaultSettings).map(([key, value]) => {
464 if (value && typeof value === 'object' && 'default' in value) {
465 return [key, value.default];
466 }
467 return [key, value];
468 }),
469 );
470
471 for (const key in settings) {
472 if (key in this.settings) {
473 this.settings[key] = settings[key];
474 } else {
475 console.warn(`Invalid setting passed to MiniMax TTS Provider: ${key}`);
476 }
477 }
478
479 // Ensure custom configuration arrays exist
480 if (!this.settings.customModels) this.settings.customModels = [];
481 if (!this.settings.customVoices) this.settings.customVoices = [];
482
483 // # Migrate settings
484 // Pitch value changed from float to int. If it's a float, let's try to extrapolate it to the new range
485 if (!Number.isInteger(this.settings.pitch)) {
486 const oldPitch = parseFloat(this.settings.pitch);
487 if (!isNaN(oldPitch)) {
488 // map old [0.5..1.0] to [-12..0], and [1.0..2.0] to [0..12] (old default was 1.0, new default is 0)
489 const newPitch = (oldPitch < 1.0) ? (oldPitch - 1.0) * 24 : (oldPitch - 1.0) * 12;
490 this.settings.pitch = Math.max(-12, Math.min(12, Math.round(newPitch)));
491 console.info(`MiniMax TTS: Migrated pitch from ${oldPitch} to ${this.settings.pitch}`);
492 } else {
493 this.settings.pitch = 0;
494 console.info(`MiniMax TTS: Migration reset pitch to default ${this.settings.pitch}`);
495 }
496 }
497
498 $('#minimax_tts_api_host').val(this.settings.apiHost || 'https://api.minimax.io');
499 $('#minimax_tts_model').val(this.settings.model);
500 $('#minimax_tts_speed').val(this.settings.speed);
501 $('#minimax_tts_volume').val(this.settings.volume);
502 $('#minimax_tts_pitch').val(this.settings.pitch);
503 $('#minimax_tts_format').val(this.settings.format);
504 $('#minimax_tts_custom_voice_id').val(this.settings.customVoiceId);
505
506 $('#minimax_connect').on('click', () => {
507 try {
508 this.onConnectClick();
509 } catch (error) {
510 console.error('MiniMax TTS: Error in connect click handler:', error);
511 toastr.error(`Connection failed: ${error.message}`);
512 }
513 });
514 $('#minimax_refresh').on('click', () => {
515 try {
516 this.onRefreshClick();
517 } catch (error) {
518 console.error('MiniMax TTS: Error in refresh click handler:', error);
519 toastr.error(`Refresh failed: ${error.message}`);
520 }
521 });
522 $('#minimax_tts_api_host').on('change', this.onSettingsChange.bind(this));
523 $('#minimax_tts_speed').on('input', this.onSettingsChange.bind(this));
524 $('#minimax_tts_volume').on('input', this.onSettingsChange.bind(this));
525 $('#minimax_tts_pitch').on('input', this.onSettingsChange.bind(this));
526 $('#minimax_tts_model').on('change', this.onSettingsChange.bind(this));
527 $('#minimax_tts_format').on('change', this.onSettingsChange.bind(this));
528 $('#minimax_tts_custom_voice_id').on('input', this.onSettingsChange.bind(this));
529
530 // Custom model and voice event listeners
531 $('#minimax_add_custom_model').on('click', () => {
532 try {
533 this.addCustomModel();
534 } catch (error) {
535 console.error('MiniMax TTS: Error adding custom model:', error);
536 toastr.error(`Failed to add custom model: ${error.message}`);
537 }
538 });
539 $('#minimax_add_custom_voice').on('click', () => {
540 try {
541 this.addCustomVoice();
542 } catch (error) {
543 console.error('MiniMax TTS: Error adding custom voice:', error);
544 toastr.error(`Failed to add custom voice: ${error.message}`);
545 }
546 });
547
548 // Keyboard event listeners
549 const ENTER_KEY = 13;
550 $('#minimax_custom_model_id, #minimax_custom_model_name').on('keypress', (e) => {
551 if (e.which === ENTER_KEY) {
552 try {
553 this.addCustomModel();
554 } catch (error) {
555 console.error('MiniMax TTS: Error adding custom model via keyboard:', error);
556 toastr.error(`Failed to add custom model: ${error.message}`);
557 }
558 }
559 });
560
561 $('#minimax_custom_voice_name, #minimax_custom_voice_id').on('keypress', (e) => {
562 if (e.which === ENTER_KEY) {
563 try {
564 this.addCustomVoice();
565 } catch (error) {
566 console.error('MiniMax TTS: Error adding custom voice via keyboard:', error);
567 toastr.error(`Failed to add custom voice: ${error.message}`);
568 }
569 }
570 });
571
572 $('#minimax_tts_speed_output').text(this.settings.speed.toFixed(1));
573 $('#minimax_tts_volume_output').text(this.settings.volume.toFixed(1));
574 $('#minimax_tts_pitch_output').text(this.settings.pitch);
575
576 // Initialize custom configuration display
577 this.updateCustomModelsDisplay();
578 this.updateCustomVoicesDisplay();
579
580 // Update model selector to include custom models
581 this.updateModelSelect(this.getAllModels());
582
583 // Initialize voice map for character voice assignment
584 try {
585 await initVoiceMap();
586 } catch (error) {
587 console.debug('MiniMax: Voice map initialization failed, but continuing');
588 }
589
590 $('#api_key_minimax').toggleClass('success', !!secret_state[SECRET_KEYS.MINIMAX]);
591 $('#minimax_group_id').toggleClass('success', !!secret_state[SECRET_KEYS.MINIMAX_GROUP_ID]);
592 [event_types.SECRET_WRITTEN, event_types.SECRET_DELETED, event_types.SECRET_ROTATED].forEach(event => {
593 eventSource.on(event, this.handler);
594 });
595
596 // Only check ready status when API credentials are available
597 if (secret_state[SECRET_KEYS.MINIMAX] && secret_state[SECRET_KEYS.MINIMAX_GROUP_ID]) {
598 try {
599 await this.checkReady();
600 console.debug('MiniMax TTS: Settings loaded and ready');
601 } catch (error) {
602 console.debug('MiniMax TTS: Settings loaded, but not ready:', error);
603 }
604 } else {
605 console.debug('MiniMax TTS: Settings loaded, waiting for API credentials');
606 }
607 }
608
609 // Perform a simple readiness check
610 async checkReady() {
611 if (!secret_state[SECRET_KEYS.MINIMAX] || !secret_state[SECRET_KEYS.MINIMAX_GROUP_ID]) {
612 const error = new Error('API Key and Group ID are required');
613 console.error('MiniMax TTS checkReady error:', error.message);
614 throw error;
615 }
616 // Try to fetch available models and voices, but don't block connection on failure
617 try {
618 await this.updateModelsAndVoices();
619 } catch (error) {
620 console.warn('MiniMax TTS: Failed to fetch models/voices during ready check, will use all available:', error);
621 // Even if API call fails, set all available values to ensure basic functionality
622 this.availableModels = this.getAllModels();
623 this.availableVoices = this.getAllVoices();
624 }
625
626 // Ensure at least voices are available
627 if (!this.availableVoices || this.availableVoices.length === 0) {
628 this.availableVoices = this.getAllVoices();
629 }
630 }
631
632 async onRefreshClick() {
633 try {
634 await this.updateModelsAndVoices();
635 await initVoiceMap(); // Update voice map after refresh
636 toastr.success('MiniMax TTS: Models and voices refreshed successfully');
637 } catch (error) {
638 toastr.error(`MiniMax TTS: Failed to refresh - ${error.message}`);
639 }
640 }
641
642 async onConnectClick() {
643 try {
644 await this.checkReady();
645 await initVoiceMap(); // Update voice map after connection
646 toastr.success('MiniMax TTS: Connected successfully');
647 saveTtsProviderSettings();
648 } catch (error) {
649 toastr.error(`MiniMax TTS: ${error.message}`);
650 }
651 }
652
653 async getVoice(voiceName) {
654 if (!voiceName) {
655 const error = new Error('TTS Voice name not provided');
656 console.error('MiniMax TTS getVoice error:', error.message);
657 throw error;
658 }
659
660 // If no available voices, try to fetch them
661 if (!this.availableVoices || this.availableVoices.length === 0) {
662 this.availableVoices = await this.fetchTtsVoiceObjects();
663 }
664
665 // Ensure at least voices are available
666 if (!this.availableVoices || this.availableVoices.length === 0) {
667 this.availableVoices = this.getAllVoices();
668 }
669
670 const voice = this.availableVoices.find(voice =>
671 voice.voice_id === voiceName || voice.name === voiceName,
672 );
673
674 if (!voice) {
675 const error = new Error(`TTS Voice not found: ${voiceName}`);
676 console.error('MiniMax TTS getVoice error:', error.message);
677 throw error;
678 }
679
680 return voice;
681 }
682
683 async generateTts(text, voiceId) {
684 // If voiceId is 'customVoice', use the custom voice ID from settings
685 if (voiceId === 'customVoice') {
686 const customVoiceId = this.settings.customVoiceId;
687 if (!customVoiceId || customVoiceId.trim() === '') {
688 const error = new Error('Please enter custom voice ID in settings first');
689 console.error('MiniMax TTS generateTts error:', error.message);
690 throw error;
691 }
692 voiceId = customVoiceId.trim();
693 }
694
695 // Get the voice object to determine language
696 let language = null;
697 try {
698 const voice = await this.getVoice(voiceId);
699 if (voice && voice.lang) {
700 language = this.mapLanguageToMiniMaxFormat(voice.lang);
701 console.debug(`MiniMax TTS: Using voice language ${voice.lang}, API language: ${language}`);
702 }
703 } catch (error) {
704 console.debug('MiniMax TTS: Could not determine voice language, using default');
705 }
706
707 return await this.fetchTtsGeneration(text, voiceId, language);
708 }
709
710 async fetchTtsVoiceObjects() {
711 try {
712 if (!secret_state[SECRET_KEYS.MINIMAX] || !secret_state[SECRET_KEYS.MINIMAX_GROUP_ID]) {
713 console.warn('MiniMax TTS: API Key and Group ID required for fetching voices');
714 console.warn('Using all available voices (default + custom). Please check your API credentials');
715 return this.getAllVoices();
716 }
717
718 // MiniMax API doesn't provide a voices listing endpoint
719 // Using all available voices (default + custom)
720 console.info('MiniMax TTS: Using all available voices (default + custom)');
721 return this.getAllVoices();
722 } catch (error) {
723 console.error('Error fetching MiniMax voices:', error);
724 console.warn('Using all available voices (default + custom). Please check your API credentials');
725 return this.getAllVoices();
726 }
727 }
728
729 async fetchTtsModels() {
730 // MiniMax API doesn't provide a models listing endpoint
731 // Using all available models (default + custom)
732 console.info('MiniMax TTS: Using all available models (default + custom)');
733 this.availableModels = this.getAllModels();
734 return this.getAllModels();
735 }
736
737 async updateModelsAndVoices() {
738 try {
739 // Get models list
740 this.availableModels = await this.fetchTtsModels();
741 console.info(`MiniMax TTS: Loaded ${this.availableModels.length} models`);
742
743 // Get voices list (now fetched from API)
744 this.availableVoices = await this.fetchTtsVoiceObjects();
745 console.info(`MiniMax TTS: Loaded ${this.availableVoices.length} voices`);
746
747 // Update model dropdown
748 this.updateModelSelect(this.availableModels);
749
750 return {
751 models: this.availableModels,
752 voices: this.availableVoices,
753 };
754 } catch (error) {
755 console.error('MiniMax TTS: Failed to update models and voices:', error);
756 // Set all available values to ensure basic functionality
757 this.availableModels = this.getAllModels();
758 this.availableVoices = this.getAllVoices();
759 throw error;
760 }
761 }
762
763 // Get correct MIME type
764 getAudioMimeType(format) {
765 const mimeTypes = {
766 'mp3': 'audio/mpeg',
767 'wav': 'audio/wav',
768 'pcm': 'audio/pcm',
769 'flac': 'audio/flac',
770 'aac': 'audio/aac',
771 };
772 return mimeTypes[format] || 'audio/mpeg';
773 }
774
775 async fetchTtsGeneration(inputText, voiceId, language = null) {
776 console.info(`Generating new MiniMax TTS for voice_id ${voiceId}`);
777
778 if (!secret_state[SECRET_KEYS.MINIMAX] || !secret_state[SECRET_KEYS.MINIMAX_GROUP_ID]) {
779 const error = new Error('API Key and Group ID are required');
780 console.error('MiniMax TTS fetchTtsGeneration error:', error.message);
781 throw error;
782 }
783
784 /** @param {number} number @param {number} lower @param {number} upper @returns {number} */
785 const clamp = (number, lower, upper) => Math.min(Math.max(number, lower), upper);
786
787 const requestBody = {
788 text: inputText,
789 voiceId: voiceId,
790 apiHost: this.settings.apiHost,
791 model: this.settings.model || this.defaultSettings.model,
792 speed: clamp(Number(this.settings.speed) || this.defaultSettings.speed.default, this.defaultSettings.speed.min, this.defaultSettings.speed.max),
793 volume: clamp(Number(this.settings.volume) || this.defaultSettings.volume.default, this.defaultSettings.volume.min, this.defaultSettings.volume.max),
794 pitch: clamp(Math.round(Number(this.settings.pitch)) || this.defaultSettings.pitch.default, this.defaultSettings.pitch.min, this.defaultSettings.pitch.max),
795 audioSampleRate: Number(this.settings.audioSampleRate) || this.defaultSettings.audioSampleRate,
796 bitrate: Number(this.settings.bitrate) || this.defaultSettings.bitrate,
797 format: this.settings.format || this.defaultSettings.format,
798 language: language,
799 };
800
801 console.debug('MiniMax TTS Request:', {
802 body: { ...requestBody, voiceId: '[REDACTED]' },
803 });
804
805 try {
806 const response = await fetch('/api/minimax/generate-voice', {
807 method: 'POST',
808 headers: getRequestHeaders(),
809 body: JSON.stringify(requestBody),
810 });
811
812 if (!response.ok) {
813 let errorMessage = `HTTP ${response.status}`;
814
815 try {
816 // Try to parse JSON error response from backend
817 const errorData = await response.json();
818 console.error('MiniMax TTS backend error:', errorData);
819 errorMessage = errorData.error || errorMessage;
820 } catch (jsonError) {
821 // If not JSON, try to read text
822 try {
823 const errorText = await response.text();
824 console.error('MiniMax TTS backend error (Text):', errorText);
825 errorMessage = errorText || errorMessage;
826 } catch (textError) {
827 console.error('MiniMax TTS: Failed to read error response:', textError);
828 }
829 }
830
831 toastr.error(`${errorMessage}`, 'MiniMax TTS Generation Failed');
832 const error = new Error(errorMessage);
833 console.error('MiniMax TTS fetchTtsGeneration error:', error.message);
834 throw error;
835 }
836
837 // Backend handles all the complex processing and returns audio data directly
838 console.debug('MiniMax TTS: Audio response received from backend');
839 return response;
840 } catch (error) {
841 console.error('Error in MiniMax TTS generation:', error);
842 throw error;
843 }
844 }
845
846 /**
847 * Map language codes to MiniMax API supported language format
848 * @param {string} lang Language code or display name
849 * @returns {string} MiniMax API language format
850 */
851 mapLanguageToMiniMaxFormat(lang) {
852 // Convert display name to language code if needed
853 const languageCode = this.convertDisplayNameToLanguageCode(lang);
854
855 // Then map language codes to MiniMax API format
856 const languageMap = {
857 'zh-CN': 'zh_CN',
858 'zh-TW': 'zh_TW',
859 'en-US': 'en_US',
860 'en-GB': 'en_GB',
861 'en-AU': 'en_AU',
862 'en-IN': 'en_IN',
863 'ja-JP': 'ja_JP',
864 'ko-KR': 'ko_KR',
865 'fr-FR': 'fr_FR',
866 'de-DE': 'de_DE',
867 'es-ES': 'es_ES',
868 'pt-BR': 'pt_BR',
869 'it-IT': 'it_IT',
870 'ar-SA': 'ar_SA',
871 'ru-RU': 'ru_RU',
872 'tr-TR': 'tr_TR',
873 'nl-NL': 'nl_NL',
874 'uk-UA': 'uk_UA',
875 'vi-VN': 'vi_VN',
876 'id-ID': 'id_ID',
877 'th-TH': 'th_TH',
878 'pl-PL': 'pl_PL',
879 'ro-RO': 'ro_RO',
880 'el-GR': 'el_GR',
881 'cs-CZ': 'cs_CZ',
882 'fi-FI': 'fi_FI',
883 'hi-IN': 'hi_IN',
884 };
885
886 // Return mapped language or default to auto
887 return languageMap[languageCode] || 'auto';
888 }
889
890 /**
891 * Preview TTS for a given voice ID.
892 * @param {string} voiceId Voice ID
893 */
894 async previewTtsVoice(voiceId) {
895 this.audioElement.pause();
896 this.audioElement.currentTime = 0;
897
898 try {
899 const voice = await this.getVoice(voiceId);
900 // Get preview text based on voice language, defaulting to en-US
901 const previewLang = voice.lang || 'en-US';
902 const text = getPreviewString(previewLang);
903
904 // Map the language to MiniMax API format for the request
905 const apiLang = this.mapLanguageToMiniMaxFormat(previewLang);
906 console.debug(`MiniMax TTS: Using preview language ${previewLang}, API language: ${apiLang}`);
907
908 const response = await this.fetchTtsGeneration(text, voiceId, apiLang);
909
910 if (!response.ok) {
911 const errorText = await response.text();
912 const error = new Error(`HTTP ${response.status}: ${errorText}`);
913 console.error('MiniMax TTS previewTtsVoice error:', error.message);
914 throw error;
915 }
916
917 const audio = await response.blob();
918 console.debug(`MiniMax TTS: Audio blob size: ${audio.size}, type: ${audio.type}`);
919
920 // Use the same method as other TTS providers - convert to base64 data URL
921 const srcUrl = await getBase64Async(audio);
922 console.debug('MiniMax TTS: Base64 data URL created');
923
924 // Clean up previous event listener to prevent memory leaks
925 this.audioElement.onended = null;
926 this.audioElement.onerror = null;
927
928 this.audioElement.src = srcUrl;
929 this.audioElement.volume = Math.min(this.settings.volume || 1.0, 1.0); // HTML audio element max is 1.0
930
931 // Add error handler for audio element
932 this.audioElement.onerror = (e) => {
933 console.error('MiniMax TTS: Audio element error:', e);
934 console.error('MiniMax TTS: Audio element error details:', {
935 error: this.audioElement.error,
936 networkState: this.audioElement.networkState,
937 readyState: this.audioElement.readyState,
938 src: this.audioElement.src,
939 });
940
941 toastr.error('Audio playback failed. The audio format may not be supported by your browser.');
942 };
943
944 try {
945 await this.audioElement.play();
946 console.debug('MiniMax TTS: Audio playback started successfully');
947 } catch (playError) {
948 console.error('MiniMax TTS: Play error:', playError);
949 throw new Error(`Audio playback failed: ${playError.message}`);
950 }
951
952 this.audioElement.onended = () => {
953 this.audioElement.onended = null;
954 this.audioElement.onerror = null;
955 };
956 } catch (error) {
957 console.error('MiniMax TTS Preview Error:', error);
958 toastr.error(`Could not generate preview: ${error.message}`);
959 }
960 }
961}