| 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: { 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 | } |