Refactor ElevenLabs TTS API key handling (#4906) * Refactor ElevenLabs TTS API key handling #4483 * Remove unused connection button and related event handler from ElevenLabs TTS provider * Add ElevenLabs STT endpoint
Signed| @@ -1,23 +1,20 @@ | |||
| 1 | import { saveTtsProviderSettings } from './index.js'; | 1 | import { saveTtsProviderSettings } from './index.js'; |
| 2 | import { event_types, eventSource, getRequestHeaders } from '/script.js'; | ||
| 3 | import { SECRET_KEYS, secret_state, writeSecret } from '/scripts/secrets.js'; | ||
| 4 | import { getBase64Async } from '/scripts/utils.js'; | ||
| 2 | export { ElevenLabsTtsProvider }; | 5 | export { ElevenLabsTtsProvider }; |
| 3 | 6 | ||
| 4 | class ElevenLabsTtsProvider { | 7 | class ElevenLabsTtsProvider { |
| 5 | //########// | ||
| 6 | // Config // | ||
| 7 | //########// | ||
| 8 | |||
| 9 | settings; | 8 | settings; |
| 10 | voices = []; | 9 | voices = []; |
| 11 | separator = ' ... ... ... '; | 10 | separator = ' ... ... ... '; |
| 12 | 11 | ||
| 13 | |||
| 14 | defaultSettings = { | 12 | defaultSettings = { |
| 15 | stability: 0.75, | 13 | stability: 0.75, |
| 16 | similarity_boost: 0.75, | 14 | similarity_boost: 0.75, |
| 17 | style_exaggeration: 0.00, | 15 | style_exaggeration: 0.00, |
| 18 | speaker_boost: true, | 16 | speaker_boost: true, |
| 19 | speed: 1.0, | 17 | speed: 1.0, |
| 20 | apiKey: '', | ||
| 21 | model: 'eleven_turbo_v2_5', | 18 | model: 'eleven_turbo_v2_5', |
| 22 | voiceMap: {}, | 19 | voiceMap: {}, |
| 23 | }; | 20 | }; |
| @@ -25,8 +22,15 @@ class ElevenLabsTtsProvider { | |||
| 25 | get settingsHtml() { | 22 | get settingsHtml() { |
| 26 | let html = ` | 23 | let html = ` |
| 27 | <div class="elevenlabs_tts_settings"> | 24 | <div class="elevenlabs_tts_settings"> |
| 28 | <label for="elevenlabs_tts_api_key">API Key</label> | 25 | <div class="flex-container alignItemsBaseline"> |
| 29 | <input id="elevenlabs_tts_api_key" type="text" class="text_pole" placeholder="<API Key>"/> | 26 | <h4 for="elevenlabs_tts_key" class="flex1 margin0"> |
| 27 | <a href="https://elevenlabs.io/app/developers/api-keys" target="_blank">ElevenLabs TTS Key</a> | ||
| 28 | </h4> | ||
| 29 | <div id="elevenlabs_tts_key" class="menu_button menu_button_icon manage-api-keys" data-key="api_key_elevenlabs"> | ||
| 30 | <i class="fa-solid fa-key"></i> | ||
| 31 | <span>Click to set</span> | ||
| 32 | </div> | ||
| 33 | </div> | ||
| 30 | <label for="elevenlabs_tts_model">Model</label> | 34 | <label for="elevenlabs_tts_model">Model</label> |
| 31 | <select id="elevenlabs_tts_model" class="text_pole"> | 35 | <select id="elevenlabs_tts_model" class="text_pole"> |
| 32 | <option value="eleven_v3">Eleven v3</option> | 36 | <option value="eleven_v3">Eleven v3</option> |
| @@ -39,7 +43,6 @@ class ElevenLabsTtsProvider { | |||
| 39 | <option value="eleven_multilingual_v1">Multilingual v1 (Old)</option> | 43 | <option value="eleven_multilingual_v1">Multilingual v1 (Old)</option> |
| 40 | <option value="eleven_turbo_v2">Turbo v2 (Old)</option> | 44 | <option value="eleven_turbo_v2">Turbo v2 (Old)</option> |
| 41 | </select> | 45 | </select> |
| 42 | <input id="eleven_labs_connect" class="menu_button" type="button" value="Connect" /> | ||
| 43 | <label for="elevenlabs_tts_stability">Stability: <span id="elevenlabs_tts_stability_output"></span></label> | 46 | <label for="elevenlabs_tts_stability">Stability: <span id="elevenlabs_tts_stability_output"></span></label> |
| 44 | <input id="elevenlabs_tts_stability" type="range" value="${this.defaultSettings.stability}" min="0" max="1" step="0.01" /> | 47 | <input id="elevenlabs_tts_stability" type="range" value="${this.defaultSettings.stability}" min="0" max="1" step="0.01" /> |
| 45 | <label for="elevenlabs_tts_similarity_boost">Similarity Boost: <span id="elevenlabs_tts_similarity_boost_output"></span></label> | 48 | <label for="elevenlabs_tts_similarity_boost">Similarity Boost: <span id="elevenlabs_tts_similarity_boost_output"></span></label> |
| @@ -72,6 +75,20 @@ class ElevenLabsTtsProvider { | |||
| 72 | return html; | 75 | return html; |
| 73 | } | 76 | } |
| 74 | 77 | ||
| 78 | constructor() { | ||
| 79 | this.handler = async function (/** @type {string} */ key) { | ||
| 80 | if (key !== SECRET_KEYS.ELEVENLABS) return; | ||
| 81 | $('#elevenlabs_tts_key').toggleClass('success', !!secret_state[SECRET_KEYS.ELEVENLABS]); | ||
| 82 | await this.fetchTtsVoiceObjects(); | ||
| 83 | }.bind(this); | ||
| 84 | } | ||
| 85 | |||
| 86 | dispose() { | ||
| 87 | [event_types.SECRET_WRITTEN, event_types.SECRET_DELETED, event_types.SECRET_ROTATED].forEach(event => { | ||
| 88 | eventSource.removeListener(event, this.handler); | ||
| 89 | }); | ||
| 90 | } | ||
| 91 | |||
| 75 | shouldInvolveExtendedSettings() { | 92 | shouldInvolveExtendedSettings() { |
| 76 | // Models that support extended settings (style_exaggeration, speaker_boost) | 93 | // Models that support extended settings (style_exaggeration, speaker_boost) |
| 77 | const modelsWithExtendedSettings = [ | 94 | const modelsWithExtendedSettings = [ |
| @@ -114,6 +131,18 @@ class ElevenLabsTtsProvider { | |||
| 114 | delete settings['multilingual']; | 131 | delete settings['multilingual']; |
| 115 | } | 132 | } |
| 116 | 133 | ||
| 134 | if (Object.hasOwn(settings, 'apiKey')) { | ||
| 135 | if (settings.apiKey && !secret_state[SECRET_KEYS.ELEVENLABS]){ | ||
| 136 | await writeSecret(SECRET_KEYS.ELEVENLABS, settings.apiKey); | ||
| 137 | } | ||
| 138 | delete settings['apiKey']; | ||
| 139 | } | ||
| 140 | |||
| 141 | $('#elevenlabs_tts_key').toggleClass('success', !!secret_state[SECRET_KEYS.ELEVENLABS]); | ||
| 142 | [event_types.SECRET_WRITTEN, event_types.SECRET_DELETED, event_types.SECRET_ROTATED].forEach(event => { | ||
| 143 | eventSource.on(event, this.handler); | ||
| 144 | }); | ||
| 145 | |||
| 117 | for (const key in settings) { | 146 | for (const key in settings) { |
| 118 | if (key in this.settings) { | 147 | if (key in this.settings) { |
| 119 | this.settings[key] = settings[key]; | 148 | this.settings[key] = settings[key]; |
| @@ -127,9 +156,7 @@ class ElevenLabsTtsProvider { | |||
| 127 | $('#elevenlabs_tts_style_exaggeration').val(this.settings.style_exaggeration); | 156 | $('#elevenlabs_tts_style_exaggeration').val(this.settings.style_exaggeration); |
| 128 | $('#elevenlabs_tts_speaker_boost').prop('checked', this.settings.speaker_boost); | 157 | $('#elevenlabs_tts_speaker_boost').prop('checked', this.settings.speaker_boost); |
| 129 | $('#elevenlabs_tts_speed').val(this.settings.speed); | 158 | $('#elevenlabs_tts_speed').val(this.settings.speed); |
| 130 | $('#elevenlabs_tts_api_key').val(this.settings.apiKey); | ||
| 131 | $('#elevenlabs_tts_model').val(this.settings.model); | 159 | $('#elevenlabs_tts_model').val(this.settings.model); |
| 132 | $('#eleven_labs_connect').on('click', () => { this.onConnectClick(); }); | ||
| 133 | $('#elevenlabs_tts_similarity_boost').on('input', this.onSettingsChange.bind(this)); | 160 | $('#elevenlabs_tts_similarity_boost').on('input', this.onSettingsChange.bind(this)); |
| 134 | $('#elevenlabs_tts_stability').on('input', this.onSettingsChange.bind(this)); | 161 | $('#elevenlabs_tts_stability').on('input', this.onSettingsChange.bind(this)); |
| 135 | $('#elevenlabs_tts_style_exaggeration').on('input', this.onSettingsChange.bind(this)); | 162 | $('#elevenlabs_tts_style_exaggeration').on('input', this.onSettingsChange.bind(this)); |
| @@ -157,23 +184,17 @@ class ElevenLabsTtsProvider { | |||
| 157 | } | 184 | } |
| 158 | 185 | ||
| 159 | async onRefreshClick() { | 186 | async onRefreshClick() { |
| 160 | } | 187 | await this.fetchTtsVoiceObjects(); |
| 161 | |||
| 162 | async onConnectClick() { | ||
| 163 | // Update on Apply click | ||
| 164 | return await this.updateApiKey().catch((error) => { | ||
| 165 | toastr.error(`ElevenLabs: ${error}`); | ||
| 166 | }); | ||
| 167 | } | 188 | } |
| 168 | 189 | ||
| 169 | setupVoiceCloningMenu() { | 190 | setupVoiceCloningMenu() { |
| 170 | const audioFilesInput = document.getElementById('elevenlabs_tts_audio_files'); | 191 | const audioFilesInput = /** @type {HTMLInputElement} */ (document.getElementById('elevenlabs_tts_audio_files')); |
| 171 | const selectedFilesListElement = document.getElementById('elevenlabs_tts_selected_files_list'); | 192 | const selectedFilesListElement = document.getElementById('elevenlabs_tts_selected_files_list'); |
| 172 | const cloneVoiceButton = document.getElementById('elevenlabs_tts_clone_voice_button'); | 193 | const cloneVoiceButton = document.getElementById('elevenlabs_tts_clone_voice_button'); |
| 173 | const uploadAudioFileButton = document.getElementById('upload_audio_file'); | 194 | const uploadAudioFileButton = document.getElementById('upload_audio_file'); |
| 174 | const voiceCloningNameInput = document.getElementById('elevenlabs_tts_voice_cloning_name'); | 195 | const voiceCloningNameInput = /** @type {HTMLInputElement} */ (document.getElementById('elevenlabs_tts_voice_cloning_name')); |
| 175 | const voiceCloningDescriptionInput = document.getElementById('elevenlabs_tts_voice_cloning_description'); | 196 | const voiceCloningDescriptionInput = /** @type {HTMLInputElement} */ (document.getElementById('elevenlabs_tts_voice_cloning_description')); |
| 176 | const voiceCloningLabelsInput = document.getElementById('elevenlabs_tts_voice_cloning_labels'); | 197 | const voiceCloningLabelsInput = /** @type {HTMLInputElement} */ (document.getElementById('elevenlabs_tts_voice_cloning_labels')); |
| 177 | 198 | ||
| 178 | const updateCloneVoiceButtonVisibility = () => { | 199 | const updateCloneVoiceButtonVisibility = () => { |
| 179 | cloneVoiceButton.style.display = audioFilesInput.files.length > 0 ? 'inline-block' : 'none'; | 200 | cloneVoiceButton.style.display = audioFilesInput.files.length > 0 ? 'inline-block' : 'none'; |
| @@ -224,22 +245,11 @@ class ElevenLabsTtsProvider { | |||
| 224 | updateCloneVoiceButtonVisibility(); | 245 | updateCloneVoiceButtonVisibility(); |
| 225 | } | 246 | } |
| 226 | 247 | ||
| 227 | async updateApiKey() { | 248 | /** |
| 228 | // Using this call to validate API key | 249 | * Get voice object by name |
| 229 | this.settings.apiKey = $('#elevenlabs_tts_api_key').val(); | 250 | * @param {string} voiceName Voice name to look up |
| 230 | 251 | * @returns {Promise<Object>} Voice object | |
| 231 | await this.fetchTtsVoiceObjects().catch(error => { | 252 | */ |
| 232 | throw 'TTS API key validation failed'; | ||
| 233 | }); | ||
| 234 | console.debug(`Saved new API_KEY: ${this.settings.apiKey}`); | ||
| 235 | $('#tts_status').text(''); | ||
| 236 | this.onSettingsChange(); | ||
| 237 | } | ||
| 238 | |||
| 239 | //#################// | ||
| 240 | // TTS Interfaces // | ||
| 241 | //#################// | ||
| 242 | |||
| 243 | async getVoice(voiceName) { | 253 | async getVoice(voiceName) { |
| 244 | if (this.voices.length == 0) { | 254 | if (this.voices.length == 0) { |
| 245 | this.voices = await this.fetchTtsVoiceObjects(); | 255 | this.voices = await this.fetchTtsVoiceObjects(); |
| @@ -253,25 +263,30 @@ class ElevenLabsTtsProvider { | |||
| 253 | return match; | 263 | return match; |
| 254 | } | 264 | } |
| 255 | 265 | ||
| 256 | 266 | /** | |
| 267 | * Generate TTS audio | ||
| 268 | * @param {string} text Text to synthesize | ||
| 269 | * @param {string} voiceId Voice ID to use for synthesis | ||
| 270 | * @returns {Promise<Response>} Response object containing audio data | ||
| 271 | */ | ||
| 257 | async generateTts(text, voiceId) { | 272 | async generateTts(text, voiceId) { |
| 258 | const historyId = await this.findTtsGenerationInHistory(text, voiceId); | 273 | const historyId = await this.findTtsGenerationInHistory(text, voiceId); |
| 259 | 274 | ||
| 260 | let response; | ||
| 261 | if (historyId) { | 275 | if (historyId) { |
| 262 | console.debug(`Found existing TTS generation with id ${historyId}`); | 276 | console.debug(`Found existing TTS generation with id ${historyId}`); |
| 263 | response = await this.fetchTtsFromHistory(historyId); | 277 | return await this.fetchTtsFromHistory(historyId); |
| 264 | } else { | 278 | } else { |
| 265 | console.debug('No existing TTS generation found, requesting new generation'); | 279 | console.debug('No existing TTS generation found, requesting new generation'); |
| 266 | response = await this.fetchTtsGeneration(text, voiceId); | 280 | return await this.fetchTtsGeneration(text, voiceId); |
| 267 | } | 281 | } |
| 268 | return response; | ||
| 269 | } | 282 | } |
| 270 | 283 | ||
| 271 | //###################// | 284 | /** |
| 272 | // Helper Functions // | 285 | * Find existing TTS generation in history |
| 273 | //###################// | 286 | * @param {string} message Message text used for TTS generation |
| 274 | 287 | * @param {string} voiceId Voice ID used for TTS generation | |
| 288 | * @returns {Promise<string>} History item ID if found, empty string otherwise | ||
| 289 | */ | ||
| 275 | async findTtsGenerationInHistory(message, voiceId) { | 290 | async findTtsGenerationInHistory(message, voiceId) { |
| 276 | const ttsHistory = await this.fetchTtsHistory(); | 291 | const ttsHistory = await this.fetchTtsHistory(); |
| 277 | for (const history of ttsHistory) { | 292 | for (const history of ttsHistory) { |
| @@ -285,40 +300,35 @@ class ElevenLabsTtsProvider { | |||
| 285 | return ''; | 300 | return ''; |
| 286 | } | 301 | } |
| 287 | 302 | ||
| 288 | |||
| 289 | //###########// | ||
| 290 | // API CALLS // | ||
| 291 | //###########// | ||
| 292 | async fetchTtsVoiceObjects() { | 303 | async fetchTtsVoiceObjects() { |
| 293 | const headers = { | 304 | const response = await fetch('/api/speech/elevenlabs/voices', { |
| 294 | 'xi-api-key': this.settings.apiKey, | 305 | method: 'POST', |
| 295 | }; | 306 | headers: getRequestHeaders({ omitContentType: true }), |
| 296 | const response = await fetch('https://api.elevenlabs.io/v1/voices', { | ||
| 297 | headers: headers, | ||
| 298 | }); | 307 | }); |
| 299 | if (!response.ok) { | 308 | if (!response.ok) { |
| 300 | throw new Error(`HTTP ${response.status}: ${await response.text()}`); | 309 | throw new Error(`HTTP ${response.status}. See server console for details.`); |
| 301 | } | 310 | } |
| 302 | const responseJson = await response.json(); | 311 | const responseJson = await response.json(); |
| 303 | return responseJson.voices; | 312 | return responseJson.voices; |
| 304 | } | 313 | } |
| 305 | 314 | ||
| 306 | async fetchTtsVoiceSettings() { | 315 | async fetchTtsVoiceSettings() { |
| 307 | const headers = { | 316 | const response = await fetch('/api/speech/elevenlabs/voice-settings', { |
| 308 | 'xi-api-key': this.settings.apiKey, | 317 | method: 'POST', |
| 309 | }; | 318 | headers: getRequestHeaders({ omitContentType: true }), |
| 310 | const response = await fetch( | 319 | }); |
| 311 | 'https://api.elevenlabs.io/v1/voices/settings/default', | ||
| 312 | { | ||
| 313 | headers: headers, | ||
| 314 | }, | ||
| 315 | ); | ||
| 316 | if (!response.ok) { | 320 | if (!response.ok) { |
| 317 | throw new Error(`HTTP ${response.status}: ${await response.text()}`); | 321 | throw new Error(`HTTP ${response.status}. See server console for details.`); |
| 318 | } | 322 | } |
| 319 | return response.json(); | 323 | return response.json(); |
| 320 | } | 324 | } |
| 321 | 325 | ||
| 326 | /** | ||
| 327 | * Fetch new TTS generation from ElevenLabs API | ||
| 328 | * @param {string} text Text to synthesize | ||
| 329 | * @param {string} voiceId Voice ID to use for synthesis | ||
| 330 | * @returns {Promise<Response>} Response object containing audio data | ||
| 331 | */ | ||
| 322 | async fetchTtsGeneration(text, voiceId) { | 332 | async fetchTtsGeneration(text, voiceId) { |
| 323 | let model = this.settings.model ?? 'eleven_monolingual_v1'; | 333 | let model = this.settings.model ?? 'eleven_monolingual_v1'; |
| 324 | console.info(`Generating new TTS for voice_id ${voiceId}, model ${model}`); | 334 | console.info(`Generating new TTS for voice_id ${voiceId}, model ${model}`); |
| @@ -335,75 +345,90 @@ class ElevenLabsTtsProvider { | |||
| 335 | request.voice_settings.style = Number(this.settings.style_exaggeration); | 345 | request.voice_settings.style = Number(this.settings.style_exaggeration); |
| 336 | request.voice_settings.use_speaker_boost = Boolean(this.settings.speaker_boost); | 346 | request.voice_settings.use_speaker_boost = Boolean(this.settings.speaker_boost); |
| 337 | } | 347 | } |
| 338 | const response = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, { | 348 | const response = await fetch('/api/speech/elevenlabs/synthesize', { |
| 339 | method: 'POST', | 349 | method: 'POST', |
| 340 | headers: { | 350 | headers: getRequestHeaders(), |
| 341 | 'xi-api-key': this.settings.apiKey, | 351 | body: JSON.stringify({ |
| 342 | 'Content-Type': 'application/json', | 352 | voiceId: voiceId, |
| 343 | }, | 353 | request: request, |
| 344 | body: JSON.stringify(request), | 354 | }), |
| 345 | }); | 355 | }); |
| 346 | if (!response.ok) { | 356 | if (!response.ok) { |
| 347 | toastr.error(response.statusText, 'TTS Generation Failed'); | 357 | toastr.error(response.statusText, 'TTS Generation Failed'); |
| 348 | throw new Error(`HTTP ${response.status}: ${await response.text()}`); | 358 | throw new Error(`HTTP ${response.status}. See server console for details.`); |
| 349 | } | 359 | } |
| 350 | return response; | 360 | return response; |
| 351 | } | 361 | } |
| 352 | 362 | ||
| 353 | async fetchTtsFromHistory(history_item_id) { | 363 | /** |
| 354 | console.info(`Fetched existing TTS with history_item_id ${history_item_id}`); | 364 | * Fetch existing TTS audio from history |
| 355 | const response = await fetch( | 365 | * @param {string} historyItemId History item ID to fetch audio for |
| 356 | `https://api.elevenlabs.io/v1/history/${history_item_id}/audio`, | 366 | * @returns {Promise<Response>} Response object containing audio data |
| 357 | { | 367 | */ |
| 358 | headers: { | 368 | async fetchTtsFromHistory(historyItemId) { |
| 359 | 'xi-api-key': this.settings.apiKey, | 369 | console.info(`Fetched existing TTS with history_item_id ${historyItemId}`); |
| 360 | }, | 370 | const response = await fetch('/api/speech/elevenlabs/history-audio', { |
| 361 | }, | 371 | method: 'POST', |
| 362 | ); | 372 | headers: getRequestHeaders(), |
| 373 | body: JSON.stringify({ | ||
| 374 | historyItemId: historyItemId, | ||
| 375 | }), | ||
| 376 | }); | ||
| 363 | if (!response.ok) { | 377 | if (!response.ok) { |
| 364 | throw new Error(`HTTP ${response.status}: ${await response.text()}`); | 378 | throw new Error(`HTTP ${response.status}. See server console for details.`); |
| 365 | } | 379 | } |
| 366 | return response; | 380 | return response; |
| 367 | } | 381 | } |
| 368 | 382 | ||
| 383 | /** | ||
| 384 | * Fetch TTS generation history | ||
| 385 | * @returns {Promise<Array>} Array of TTS history items | ||
| 386 | */ | ||
| 369 | async fetchTtsHistory() { | 387 | async fetchTtsHistory() { |
| 370 | const headers = { | 388 | const response = await fetch('/api/speech/elevenlabs/history', { |
| 371 | 'xi-api-key': this.settings.apiKey, | 389 | method: 'POST', |
| 372 | }; | 390 | headers: getRequestHeaders({ omitContentType: true }), |
| 373 | const response = await fetch('https://api.elevenlabs.io/v1/history', { | ||
| 374 | headers: headers, | ||
| 375 | }); | 391 | }); |
| 376 | if (!response.ok) { | 392 | if (!response.ok) { |
| 377 | throw new Error(`HTTP ${response.status}: ${await response.text()}`); | 393 | throw new Error(`HTTP ${response.status}. See server console for details.`); |
| 378 | } | 394 | } |
| 379 | const responseJson = await response.json(); | 395 | const responseJson = await response.json(); |
| 380 | return responseJson.history; | 396 | return responseJson.history; |
| 381 | } | 397 | } |
| 382 | 398 | ||
| 399 | /** | ||
| 400 | * Add a new voice via ElevenLabs API | ||
| 401 | * @param {string} name Voice name | ||
| 402 | * @param {string} description Voice description | ||
| 403 | * @param {string} labels Voice labels | ||
| 404 | * @returns {Promise<Object>} Newly created voice object | ||
| 405 | */ | ||
| 383 | async addVoice(name, description, labels) { | 406 | async addVoice(name, description, labels) { |
| 384 | const selected_files = document.querySelectorAll('input[type="file"][name="audio_files"]'); | 407 | const audioFilesInput = /** @type {HTMLInputElement} */ (document.getElementById('elevenlabs_tts_audio_files')); |
| 385 | const formData = new FormData(); | 408 | if (!(audioFilesInput instanceof HTMLInputElement) || audioFilesInput.files.length === 0) { |
| 409 | throw new Error('No audio files selected for voice cloning.'); | ||
| 410 | } | ||
| 386 | 411 | ||
| 387 | formData.append('name', name); | 412 | const data = { |
| 388 | formData.append('description', description); | 413 | name: name, |
| 389 | formData.append('labels', labels); | 414 | description: description, |
| 415 | labels: labels, | ||
| 416 | files: [], | ||
| 417 | }; | ||
| 390 | 418 | ||
| 391 | for (const file of selected_files) { | 419 | for (const file of audioFilesInput.files) { |
| 392 | if (file.files.length > 0) { | 420 | const base64Data = await getBase64Async(file); |
| 393 | formData.append('files', file.files[0]); | 421 | data.files.push(base64Data); |
| 394 | } | ||
| 395 | } | 422 | } |
| 396 | 423 | ||
| 397 | const response = await fetch('https://api.elevenlabs.io/v1/voices/add', { | 424 | const response = await fetch('/api/speech/elevenlabs/voices/add', { |
| 398 | method: 'POST', | 425 | method: 'POST', |
| 399 | headers: { | 426 | headers: getRequestHeaders(), |
| 400 | 'xi-api-key': this.settings.apiKey, | 427 | body: JSON.stringify(data), |
| 401 | }, | ||
| 402 | body: formData, | ||
| 403 | }); | 428 | }); |
| 404 | 429 | ||
| 405 | if (!response.ok) { | 430 | if (!response.ok) { |
| 406 | throw new Error(`HTTP ${response.status}: ${await response.text()}`); | 431 | throw new Error(`HTTP ${response.status}. See server console for details.`); |
| 407 | } | 432 | } |
| 408 | 433 | ||
| 409 | return await response.json(); | 434 | return await response.json(); |
| @@ -71,6 +71,7 @@ export const SECRET_KEYS = { | |||
| 71 | COMETAPI: 'api_key_cometapi', | 71 | COMETAPI: 'api_key_cometapi', |
| 72 | ZAI: 'api_key_zai', | 72 | ZAI: 'api_key_zai', |
| 73 | SILICONFLOW: 'api_key_siliconflow', | 73 | SILICONFLOW: 'api_key_siliconflow', |
| 74 | ELEVENLABS: 'api_key_elevenlabs', | ||
| 74 | }; | 75 | }; |
| 75 | 76 | ||
| 76 | const FRIENDLY_NAMES = { | 77 | const FRIENDLY_NAMES = { |
| @@ -130,6 +131,7 @@ const FRIENDLY_NAMES = { | |||
| 130 | [SECRET_KEYS.AZURE_OPENAI]: 'Azure OpenAI', | 131 | [SECRET_KEYS.AZURE_OPENAI]: 'Azure OpenAI', |
| 131 | [SECRET_KEYS.ZAI]: 'Z.AI', | 132 | [SECRET_KEYS.ZAI]: 'Z.AI', |
| 132 | [SECRET_KEYS.SILICONFLOW]: 'SiliconFlow', | 133 | [SECRET_KEYS.SILICONFLOW]: 'SiliconFlow', |
| 134 | [SECRET_KEYS.ELEVENLABS]: 'ElevenLabs TTS', | ||
| 133 | }; | 135 | }; |
| 134 | 136 | ||
| 135 | const INPUT_MAP = { | 137 | const INPUT_MAP = { |
| @@ -64,6 +64,7 @@ export const SECRET_KEYS = { | |||
| 64 | AZURE_OPENAI: 'api_key_azure_openai', | 64 | AZURE_OPENAI: 'api_key_azure_openai', |
| 65 | ZAI: 'api_key_zai', | 65 | ZAI: 'api_key_zai', |
| 66 | SILICONFLOW: 'api_key_siliconflow', | 66 | SILICONFLOW: 'api_key_siliconflow', |
| 67 | ELEVENLABS: 'api_key_elevenlabs', | ||
| 67 | }; | 68 | }; |
| 68 | 69 | ||
| 69 | /** | 70 | /** |
| @@ -1,9 +1,13 @@ | |||
| 1 | import { Buffer } from 'node:buffer'; | 1 | import { Buffer } from 'node:buffer'; |
| 2 | import fs from 'node:fs'; | ||
| 2 | import express from 'express'; | 3 | import express from 'express'; |
| 3 | import wavefile from 'wavefile'; | 4 | import wavefile from 'wavefile'; |
| 4 | import fetch from 'node-fetch'; | 5 | import fetch from 'node-fetch'; |
| 6 | import FormData from 'form-data'; | ||
| 7 | import mime from 'mime-types'; | ||
| 5 | import { getPipeline } from '../transformers.js'; | 8 | import { getPipeline } from '../transformers.js'; |
| 6 | import { forwardFetchResponse } from '../util.js'; | 9 | import { forwardFetchResponse } from '../util.js'; |
| 10 | import { readSecret, SECRET_KEYS } from './secrets.js'; | ||
| 7 | 11 | ||
| 8 | export const router = express.Router(); | 12 | export const router = express.Router(); |
| 9 | 13 | ||
| @@ -138,3 +142,260 @@ pollinations.post('/generate', async (req, res) => { | |||
| 138 | }); | 142 | }); |
| 139 | 143 | ||
| 140 | router.use('/pollinations', pollinations); | 144 | router.use('/pollinations', pollinations); |
| 145 | |||
| 146 | const elevenlabs = express.Router(); | ||
| 147 | |||
| 148 | elevenlabs.post('/voices', async (req, res) => { | ||
| 149 | try { | ||
| 150 | const apiKey = readSecret(req.user.directories, SECRET_KEYS.ELEVENLABS); | ||
| 151 | if (!apiKey) { | ||
| 152 | console.warn('ElevenLabs API key not found'); | ||
| 153 | return res.sendStatus(400); | ||
| 154 | } | ||
| 155 | |||
| 156 | const response = await fetch('https://api.elevenlabs.io/v1/voices', { | ||
| 157 | headers: { | ||
| 158 | 'xi-api-key': apiKey, | ||
| 159 | }, | ||
| 160 | }); | ||
| 161 | |||
| 162 | if (!response.ok) { | ||
| 163 | const text = await response.text(); | ||
| 164 | console.warn(`ElevenLabs voices fetch failed: HTTP ${response.status} - ${text}`); | ||
| 165 | return res.sendStatus(500); | ||
| 166 | } | ||
| 167 | |||
| 168 | const responseJson = await response.json(); | ||
| 169 | return res.json(responseJson); | ||
| 170 | } catch (error) { | ||
| 171 | console.error(error); | ||
| 172 | return res.sendStatus(500); | ||
| 173 | } | ||
| 174 | }); | ||
| 175 | |||
| 176 | elevenlabs.post('/voice-settings', async (req, res) => { | ||
| 177 | try { | ||
| 178 | const apiKey = readSecret(req.user.directories, SECRET_KEYS.ELEVENLABS); | ||
| 179 | if (!apiKey) { | ||
| 180 | console.warn('ElevenLabs API key not found'); | ||
| 181 | return res.sendStatus(400); | ||
| 182 | } | ||
| 183 | |||
| 184 | const response = await fetch('https://api.elevenlabs.io/v1/voices/settings/default', { | ||
| 185 | headers: { | ||
| 186 | 'xi-api-key': apiKey, | ||
| 187 | }, | ||
| 188 | }); | ||
| 189 | |||
| 190 | if (!response.ok) { | ||
| 191 | const text = await response.text(); | ||
| 192 | console.warn(`ElevenLabs voice settings fetch failed: HTTP ${response.status} - ${text}`); | ||
| 193 | return res.sendStatus(500); | ||
| 194 | } | ||
| 195 | const responseJson = await response.json(); | ||
| 196 | return res.json(responseJson); | ||
| 197 | } catch (error) { | ||
| 198 | console.error(error); | ||
| 199 | return res.sendStatus(500); | ||
| 200 | } | ||
| 201 | }); | ||
| 202 | |||
| 203 | elevenlabs.post('/synthesize', async (req, res) => { | ||
| 204 | try { | ||
| 205 | const apiKey = readSecret(req.user.directories, SECRET_KEYS.ELEVENLABS); | ||
| 206 | if (!apiKey) { | ||
| 207 | console.warn('ElevenLabs API key not found'); | ||
| 208 | return res.sendStatus(400); | ||
| 209 | } | ||
| 210 | |||
| 211 | const { voiceId, request } = req.body; | ||
| 212 | |||
| 213 | if (!voiceId || !request) { | ||
| 214 | console.warn('ElevenLabs synthesis request missing voiceId or request body'); | ||
| 215 | return res.sendStatus(400); | ||
| 216 | } | ||
| 217 | |||
| 218 | console.debug('ElevenLabs TTS request:', request); | ||
| 219 | |||
| 220 | const response = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, { | ||
| 221 | method: 'POST', | ||
| 222 | headers: { | ||
| 223 | 'xi-api-key': apiKey, | ||
| 224 | 'Content-Type': 'application/json', | ||
| 225 | }, | ||
| 226 | body: JSON.stringify(request), | ||
| 227 | }); | ||
| 228 | |||
| 229 | if (!response.ok) { | ||
| 230 | const text = await response.text(); | ||
| 231 | console.warn(`ElevenLabs synthesis failed: HTTP ${response.status} - ${text}`); | ||
| 232 | return res.sendStatus(500); | ||
| 233 | } | ||
| 234 | |||
| 235 | res.set('Content-Type', 'audio/mpeg'); | ||
| 236 | forwardFetchResponse(response, res); | ||
| 237 | } catch (error) { | ||
| 238 | console.error(error); | ||
| 239 | return res.sendStatus(500); | ||
| 240 | } | ||
| 241 | }); | ||
| 242 | |||
| 243 | elevenlabs.post('/history', async (req, res) => { | ||
| 244 | try { | ||
| 245 | const apiKey = readSecret(req.user.directories, SECRET_KEYS.ELEVENLABS); | ||
| 246 | if (!apiKey) { | ||
| 247 | console.warn('ElevenLabs API key not found'); | ||
| 248 | return res.sendStatus(400); | ||
| 249 | } | ||
| 250 | |||
| 251 | const response = await fetch('https://api.elevenlabs.io/v1/history', { | ||
| 252 | headers: { | ||
| 253 | 'xi-api-key': apiKey, | ||
| 254 | }, | ||
| 255 | }); | ||
| 256 | |||
| 257 | if (!response.ok) { | ||
| 258 | const text = await response.text(); | ||
| 259 | console.warn(`ElevenLabs history fetch failed: HTTP ${response.status} - ${text}`); | ||
| 260 | return res.sendStatus(500); | ||
| 261 | } | ||
| 262 | |||
| 263 | const responseJson = await response.json(); | ||
| 264 | return res.json(responseJson); | ||
| 265 | } catch (error) { | ||
| 266 | console.error(error); | ||
| 267 | return res.sendStatus(500); | ||
| 268 | } | ||
| 269 | }); | ||
| 270 | |||
| 271 | elevenlabs.post('/history-audio', async (req, res) => { | ||
| 272 | try { | ||
| 273 | const apiKey = readSecret(req.user.directories, SECRET_KEYS.ELEVENLABS); | ||
| 274 | if (!apiKey) { | ||
| 275 | console.warn('ElevenLabs API key not found'); | ||
| 276 | return res.sendStatus(400); | ||
| 277 | } | ||
| 278 | |||
| 279 | const { historyItemId } = req.body; | ||
| 280 | if (!historyItemId) { | ||
| 281 | console.warn('ElevenLabs history audio request missing historyItemId'); | ||
| 282 | return res.sendStatus(400); | ||
| 283 | } | ||
| 284 | |||
| 285 | console.debug('ElevenLabs history audio request for ID:', historyItemId); | ||
| 286 | |||
| 287 | const response = await fetch(`https://api.elevenlabs.io/v1/history/${historyItemId}/audio`, { | ||
| 288 | headers: { | ||
| 289 | 'xi-api-key': apiKey, | ||
| 290 | }, | ||
| 291 | }); | ||
| 292 | |||
| 293 | if (!response.ok) { | ||
| 294 | const text = await response.text(); | ||
| 295 | console.warn(`ElevenLabs history audio fetch failed: HTTP ${response.status} - ${text}`); | ||
| 296 | return res.sendStatus(500); | ||
| 297 | } | ||
| 298 | |||
| 299 | res.set('Content-Type', 'audio/mpeg'); | ||
| 300 | forwardFetchResponse(response, res); | ||
| 301 | } catch (error) { | ||
| 302 | console.error(error); | ||
| 303 | return res.sendStatus(500); | ||
| 304 | } | ||
| 305 | }); | ||
| 306 | |||
| 307 | elevenlabs.post('/voices/add', async (req, res) => { | ||
| 308 | try { | ||
| 309 | const apiKey = readSecret(req.user.directories, SECRET_KEYS.ELEVENLABS); | ||
| 310 | if (!apiKey) { | ||
| 311 | console.warn('ElevenLabs API key not found'); | ||
| 312 | return res.sendStatus(400); | ||
| 313 | } | ||
| 314 | |||
| 315 | const { name, description, labels, files } = req.body; | ||
| 316 | |||
| 317 | const formData = new FormData(); | ||
| 318 | formData.append('name', name || 'Custom Voice'); | ||
| 319 | formData.append('description', description || 'Uploaded via SillyTavern'); | ||
| 320 | formData.append('labels', labels || ''); | ||
| 321 | |||
| 322 | for (const fileData of (files || [])) { | ||
| 323 | const [mimeType, base64Data] = /^data:(.+);base64,(.+)$/.exec(fileData)?.slice(1) || []; | ||
| 324 | if (!mimeType || !base64Data) { | ||
| 325 | console.warn('Invalid audio file data provided for ElevenLabs voice upload'); | ||
| 326 | continue; | ||
| 327 | } | ||
| 328 | const buffer = Buffer.from(base64Data, 'base64'); | ||
| 329 | formData.append('files', buffer, { | ||
| 330 | filename: `audio.${mime.extension(mimeType) || 'wav'}`, | ||
| 331 | contentType: mimeType, | ||
| 332 | }); | ||
| 333 | } | ||
| 334 | |||
| 335 | console.debug('ElevenLabs voice upload request:', { name, description, labels, files: files?.length || 0 }); | ||
| 336 | |||
| 337 | const response = await fetch('https://api.elevenlabs.io/v1/voices/add', { | ||
| 338 | method: 'POST', | ||
| 339 | headers: { | ||
| 340 | 'xi-api-key': apiKey, | ||
| 341 | }, | ||
| 342 | body: formData, | ||
| 343 | }); | ||
| 344 | |||
| 345 | if (!response.ok) { | ||
| 346 | const text = await response.text(); | ||
| 347 | console.warn(`ElevenLabs voice upload failed: HTTP ${response.status} - ${text}`); | ||
| 348 | return res.sendStatus(500); | ||
| 349 | } | ||
| 350 | |||
| 351 | const responseJson = await response.json(); | ||
| 352 | return res.json(responseJson); | ||
| 353 | } catch (error) { | ||
| 354 | console.error(error); | ||
| 355 | return res.sendStatus(500); | ||
| 356 | } | ||
| 357 | }); | ||
| 358 | |||
| 359 | elevenlabs.post('/recognize', async (req, res) => { | ||
| 360 | try { | ||
| 361 | const apiKey = readSecret(req.user.directories, SECRET_KEYS.ELEVENLABS); | ||
| 362 | if (!apiKey) { | ||
| 363 | console.warn('ElevenLabs API key not found'); | ||
| 364 | return res.sendStatus(400); | ||
| 365 | } | ||
| 366 | |||
| 367 | if (!req.file) { | ||
| 368 | console.warn('No audio file found'); | ||
| 369 | return res.sendStatus(400); | ||
| 370 | } | ||
| 371 | |||
| 372 | console.info('Processing audio file with ElevenLabs', req.file.path); | ||
| 373 | const formData = new FormData(); | ||
| 374 | formData.append('file', fs.createReadStream(req.file.path), { filename: 'audio.wav', contentType: 'audio/wav' }); | ||
| 375 | formData.append('model_id', req.body.model); | ||
| 376 | |||
| 377 | const response = await fetch('https://api.elevenlabs.io/v1/speech-to-text', { | ||
| 378 | method: 'POST', | ||
| 379 | headers: { | ||
| 380 | 'xi-api-key': apiKey, | ||
| 381 | }, | ||
| 382 | body: formData, | ||
| 383 | }); | ||
| 384 | |||
| 385 | if (!response.ok) { | ||
| 386 | const text = await response.text(); | ||
| 387 | console.warn(`ElevenLabs speech recognition failed: HTTP ${response.status} - ${text}`); | ||
| 388 | return res.sendStatus(500); | ||
| 389 | } | ||
| 390 | |||
| 391 | fs.unlinkSync(req.file.path); | ||
| 392 | const responseJson = await response.json(); | ||
| 393 | console.debug('ElevenLabs speech recognition response:', responseJson); | ||
| 394 | return res.json(responseJson); | ||
| 395 | } catch (error) { | ||
| 396 | console.error(error); | ||
| 397 | return res.sendStatus(500); | ||
| 398 | } | ||
| 399 | }); | ||
| 400 | |||
| 401 | router.use('/elevenlabs', elevenlabs); | ||