| 1 | import { getRequestHeaders, substituteParams } from '../../../script.js'; |
| 2 | import { saveTtsProviderSettings, sanitizeId } from './index.js'; |
| 3 | |
| 4 | export { OpenAITtsProvider }; |
| 5 | |
| 6 | class OpenAITtsProvider { |
| 7 | static voices = [ |
| 8 | { name: 'Alloy', voice_id: 'alloy', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/alloy.wav' }, |
| 9 | { name: 'Ash', voice_id: 'ash', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/ash.wav' }, |
| 10 | { name: 'Coral', voice_id: 'coral', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/coral.wav' }, |
| 11 | { name: 'Echo', voice_id: 'echo', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/echo.wav' }, |
| 12 | { name: 'Fable', voice_id: 'fable', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/fable.wav' }, |
| 13 | { name: 'Onyx', voice_id: 'onyx', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/onyx.wav' }, |
| 14 | { name: 'Nova', voice_id: 'nova', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/nova.wav' }, |
| 15 | { name: 'Sage', voice_id: 'sage', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/sage.wav' }, |
| 16 | { name: 'Shimmer', voice_id: 'shimmer', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/shimmer.wav' }, |
| 17 | ]; |
| 18 | |
| 19 | settings; |
| 20 | voices = []; |
| 21 | separator = ' . '; |
| 22 | audioElement = document.createElement('audio'); |
| 23 | |
| 24 | defaultSettings = { |
| 25 | voiceMap: {}, |
| 26 | customVoices: [], |
| 27 | model: 'tts-1', |
| 28 | speed: 1, |
| 29 | characterInstructions: {}, |
| 30 | }; |
| 31 | |
| 32 | get settingsHtml() { |
| 33 | let html = ` |
| 34 | <div>Use OpenAI's TTS engine.</div> |
| 35 | <small>Hint: Save an API key in the OpenAI API settings to use it here.</small> |
| 36 | <div> |
| 37 | <label for="openai-tts-model">Model:</label> |
| 38 | <select id="openai-tts-model"> |
| 39 | <optgroup label="Latest"> |
| 40 | <option value="tts-1">tts-1</option> |
| 41 | <option value="tts-1-hd">tts-1-hd</option> |
| 42 | <option value="gpt-4o-mini-tts">gpt-4o-mini-tts</option> |
| 43 | </optgroup> |
| 44 | <optgroup label="Snapshots"> |
| 45 | <option value="tts-1-1106">tts-1-1106</option> |
| 46 | <option value="tts-1-hd-1106">tts-1-hd-1106</option> |
| 47 | </optgroup> |
| 48 | <select> |
| 49 | </div> |
| 50 | <div> |
| 51 | <label for="openai-tts-speed">Speed: <span id="openai-tts-speed-output"></span></label> |
| 52 | <input type="range" id="openai-tts-speed" value="1" min="0.25" max="4" step="0.05"> |
| 53 | </div>`; |
| 54 | return html; |
| 55 | } |
| 56 | |
| 57 | async loadSettings(settings) { |
| 58 | // Populate Provider UI given input settings |
| 59 | if (Object.keys(settings).length == 0) { |
| 60 | console.info('Using default TTS Provider settings'); |
| 61 | } |
| 62 | |
| 63 | // Only accept keys defined in defaultSettings |
| 64 | this.settings = this.defaultSettings; |
| 65 | |
| 66 | for (const key in settings) { |
| 67 | if (key in this.settings) { |
| 68 | this.settings[key] = settings[key]; |
| 69 | } else { |
| 70 | throw `Invalid setting passed to TTS Provider: ${key}`; |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | $('#openai-tts-model').val(this.settings.model); |
| 75 | $('#openai-tts-model').on('change', () => { |
| 76 | this.onSettingsChange(); |
| 77 | }); |
| 78 | |
| 79 | $('#openai-tts-speed').val(this.settings.speed); |
| 80 | $('#openai-tts-speed').on('input', () => { |
| 81 | this.onSettingsChange(); |
| 82 | }); |
| 83 | |
| 84 | $('#openai-tts-speed-output').text(this.settings.speed); |
| 85 | |
| 86 | await this.checkReady(); |
| 87 | // Initialize UI state based on current model (gpt-4o-mini-tts or other) |
| 88 | this.updateInstructionsUI(); |
| 89 | // Look for voice map changes |
| 90 | this.setupVoiceMapObserver(); |
| 91 | |
| 92 | console.debug('OpenAI TTS: Settings loaded'); |
| 93 | } |
| 94 | |
| 95 | setupVoiceMapObserver() { |
| 96 | if (this.voiceMapObserver) { |
| 97 | this.voiceMapObserver.disconnect(); |
| 98 | this.voiceMapObserver = null; |
| 99 | } |
| 100 | |
| 101 | const targetNode = document.getElementById('tts_voicemap_block'); |
| 102 | if (!targetNode) return; |
| 103 | |
| 104 | const observer = new MutationObserver(() => { |
| 105 | if (this.settings.model === 'gpt-4o-mini-tts') { |
| 106 | this.populateCharacterInstructions(); |
| 107 | } |
| 108 | }); |
| 109 | |
| 110 | observer.observe(targetNode, { childList: true, subtree: true }); |
| 111 | this.voiceMapObserver = observer; |
| 112 | } |
| 113 | |
| 114 | onSettingsChange() { |
| 115 | // Update dynamically |
| 116 | this.settings.model = String($('#openai-tts-model').find(':selected').val()); |
| 117 | this.settings.speed = Number($('#openai-tts-speed').val()); |
| 118 | $('#openai-tts-speed-output').text(this.settings.speed); |
| 119 | this.updateInstructionsUI(); |
| 120 | saveTtsProviderSettings(); |
| 121 | } |
| 122 | |
| 123 | updateInstructionsUI() { |
| 124 | if (this.settings.model === 'gpt-4o-mini-tts') { |
| 125 | this.createInstructionsContainer(); |
| 126 | $('#openai-instructions-container').show(); |
| 127 | this.populateCharacterInstructions(); |
| 128 | } else { |
| 129 | $('#openai-instructions-container').hide(); |
| 130 | this.voiceMapObserver?.disconnect(); |
| 131 | this.voiceMapObserver = null; |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | createInstructionsContainer() { |
| 136 | if ($('#openai-instructions-container').length === 0) { |
| 137 | const containerHtml = ` |
| 138 | <div id="openai-instructions-container" style="display: none;"> |
| 139 | <span>Voice Instructions (GPT-4o Mini TTS)</span><br> |
| 140 | <small>Customize how each character speaks</small> |
| 141 | <div id="openai-character-instructions"></div> |
| 142 | </div> |
| 143 | `; |
| 144 | $('#openai-tts-speed').parent().after(containerHtml); |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | populateCharacterInstructions() { |
| 149 | const currentCharacters = $('.tts_voicemap_block_char span').map((i, el) => $(el).text()).get(); |
| 150 | |
| 151 | $('#openai-character-instructions').empty(); |
| 152 | |
| 153 | for (const char of currentCharacters) { |
| 154 | if (char === 'SillyTavern System' || char === '[Default Voice]') continue; |
| 155 | |
| 156 | const sanitizedName = sanitizeId(char); |
| 157 | const savedInstructions = this.settings.characterInstructions?.[char] || ''; |
| 158 | |
| 159 | const instructionBlock = document.createElement('div'); |
| 160 | const label = document.createElement('label'); |
| 161 | const textArea = document.createElement('textarea'); |
| 162 | instructionBlock.appendChild(label); |
| 163 | instructionBlock.appendChild(textArea); |
| 164 | instructionBlock.className = 'character-instructions'; |
| 165 | label.setAttribute('for', `openai_char_${sanitizedName}`); |
| 166 | label.innerText = `${char}:`; |
| 167 | textArea.id = `openai_char_${sanitizedName}`; |
| 168 | textArea.placeholder = 'e.g., "Speak cheerfully and energetically"'; |
| 169 | textArea.className = 'textarea_compact autoSetHeight'; |
| 170 | textArea.value = savedInstructions; |
| 171 | textArea.addEventListener('input', () => { |
| 172 | this.saveCharacterInstructions(char, textArea.value); |
| 173 | }); |
| 174 | |
| 175 | $('#openai-character-instructions').append(instructionBlock); |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | saveCharacterInstructions(characterName, instructions) { |
| 180 | if (!this.settings.characterInstructions) { |
| 181 | this.settings.characterInstructions = {}; |
| 182 | } |
| 183 | this.settings.characterInstructions[characterName] = instructions; |
| 184 | saveTtsProviderSettings(); |
| 185 | } |
| 186 | |
| 187 | async checkReady() { |
| 188 | await this.fetchTtsVoiceObjects(); |
| 189 | } |
| 190 | |
| 191 | async onRefreshClick() { |
| 192 | return; |
| 193 | } |
| 194 | |
| 195 | async getVoice(voiceName) { |
| 196 | if (!voiceName) { |
| 197 | throw 'TTS Voice name not provided'; |
| 198 | } |
| 199 | |
| 200 | const voice = OpenAITtsProvider.voices.find(voice => voice.voice_id === voiceName || voice.name === voiceName); |
| 201 | |
| 202 | if (!voice) { |
| 203 | throw `TTS Voice not found: ${voiceName}`; |
| 204 | } |
| 205 | |
| 206 | return voice; |
| 207 | } |
| 208 | |
| 209 | async generateTts(text, voiceId, characterName = null) { |
| 210 | const response = await this.fetchTtsGeneration(text, voiceId, characterName); |
| 211 | return response; |
| 212 | } |
| 213 | |
| 214 | async fetchTtsVoiceObjects() { |
| 215 | return OpenAITtsProvider.voices; |
| 216 | } |
| 217 | |
| 218 | async previewTtsVoice(_) { |
| 219 | return; |
| 220 | } |
| 221 | |
| 222 | async fetchTtsGeneration(inputText, voiceId, characterName = null) { |
| 223 | console.info(`Generating new TTS for voice_id ${voiceId}`); |
| 224 | |
| 225 | const requestBody = { |
| 226 | 'text': inputText, |
| 227 | 'voice': voiceId, |
| 228 | 'model': this.settings.model, |
| 229 | 'speed': this.settings.speed, |
| 230 | }; |
| 231 | |
| 232 | if (this.settings.model === 'gpt-4o-mini-tts' && characterName) { |
| 233 | const instructions = this.settings.characterInstructions?.[characterName]; |
| 234 | if (instructions && instructions.trim()) { |
| 235 | requestBody.instructions = substituteParams(instructions); |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | const response = await fetch('/api/openai/generate-voice', { |
| 240 | method: 'POST', |
| 241 | headers: getRequestHeaders(), |
| 242 | body: JSON.stringify(requestBody), |
| 243 | }); |
| 244 | |
| 245 | if (!response.ok) { |
| 246 | toastr.error(response.statusText, 'TTS Generation Failed'); |
| 247 | throw new Error(`HTTP ${response.status}: ${await response.text()}`); |
| 248 | } |
| 249 | |
| 250 | return response; |
| 251 | } |
| 252 | } |