| 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 | +} |