Add gpt-4o-mini-tts model with character voice instructions #3879 (#4352) * Add gpt-4o-mini-tts model with character voice instructions * Restyle instructions block * Add debug log for tts request --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
Signed| @@ -0,0 +1,11 @@ | ||
| 1 | +#openai-character-instructions { | |
| 2 | + display: flex; | |
| 3 | + flex-direction: column; | |
| 4 | + gap: 10px; | |
| 5 | +} | |
| 6 | + | |
| 7 | +#openai-character-instructions .character-instructions { | |
| 8 | + display: flex; | |
| 9 | + flex-direction: column; | |
| 10 | + gap: 5px; | |
| 11 | +} | |
| @@ -476,7 +476,7 @@ async function tts(text, voiceId, char) { | ||
| 476 | 476 | await addAudioJob(response, char); |
| 477 | 477 | } |
| 478 | 478 | |
| 479 | 479 | let response = await ttsProvider.generateTts(text, voiceId, char); |
| 480 | 480 | |
| 481 | 481 | // If async generator, process every chunk as it comes in |
| 482 | 482 | if (typeof response[Symbol.asyncIterator] === 'function') { |
| @@ -1007,7 +1007,7 @@ function getCharacters(unrestricted) { | ||
| 1007 | 1007 | return characters.filter(onlyUnique); |
| 1008 | 1008 | } |
| 1009 | 1009 | |
| 1010 | 1010 | export function sanitizeId(input) { |
| 1011 | 1011 | // Remove any non-alphanumeric characters except underscore (_) and hyphen (-) |
| 1012 | 1012 | let sanitized = encodeURIComponent(input).replace(/[^a-zA-Z0-9-_]/g, ''); |
| 1013 | 1013 | |
| @@ -1,15 +1,18 @@ | ||
| 1 | 1 | import { getRequestHeaders } from '../../../script.js'; |
| 2 | 2 | import { saveTtsProviderSettings, sanitizeId } from './index.js'; |
| 3 | 3 | |
| 4 | 4 | export { OpenAITtsProvider }; |
| 5 | 5 | |
| 6 | 6 | class OpenAITtsProvider { |
| 7 | 7 | static voices = [ |
| 8 | 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' }, | |
| 9 | 11 | { name: 'Echo', voice_id: 'echo', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/echo.wav' }, |
| 10 | 12 | { name: 'Fable', voice_id: 'fable', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/fable.wav' }, |
| 11 | 13 | { name: 'Onyx', voice_id: 'onyx', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/onyx.wav' }, |
| 12 | 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' }, | |
| 13 | 16 | { name: 'Shimmer', voice_id: 'shimmer', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/shimmer.wav' }, |
| 14 | 17 | ]; |
| 15 | 18 | |
| @@ -23,6 +26,7 @@ class OpenAITtsProvider { | ||
| 23 | 26 | customVoices: [], |
| 24 | 27 | model: 'tts-1', |
| 25 | 28 | speed: 1, |
| 29 | + characterInstructions: {}, | |
| 26 | 30 | }; |
| 27 | 31 | |
| 28 | 32 | get settingsHtml() { |
| @@ -35,6 +39,7 @@ class OpenAITtsProvider { | ||
| 35 | 39 | <optgroup label="Latest"> |
| 36 | 40 | <option value="tts-1">tts-1</option> |
| 37 | 41 | <option value="tts-1-hd">tts-1-hd</option> |
| 42 | + <option value="gpt-4o-mini-tts">gpt-4o-mini-tts</option> | |
| 38 | 43 | </optgroup> |
| 39 | 44 | <optgroup label="Snapshots"> |
| 40 | 45 | <option value="tts-1-1106">tts-1-1106</option> |
| @@ -79,14 +84,104 @@ class OpenAITtsProvider { | ||
| 79 | 84 | $('#openai-tts-speed-output').text(this.settings.speed); |
| 80 | 85 | |
| 81 | 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 | + | |
| 82 | 92 | console.debug('OpenAI TTS: Settings loaded'); |
| 83 | 93 | } |
| 84 | 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 | + | |
| 85 | 114 | onSettingsChange() { |
| 86 | 115 | // Update dynamically |
| 87 | 116 | this.settings.model = String($('#openai-tts-model').find(':selected').val()); |
| 88 | 117 | this.settings.speed = Number($('#openai-tts-speed').val()); |
| 89 | 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 | + | |
| 150 | + const currentCharacters = $('.tts_voicemap_block_char span').map((i, el) => $(el).text()).get(); | |
| 151 | + | |
| 152 | + $('#openai-character-instructions').empty(); | |
| 153 | + | |
| 154 | + for (const char of currentCharacters) { | |
| 155 | + if (char === 'SillyTavern System' || char === '[Default Voice]') continue; | |
| 156 | + | |
| 157 | + const sanitizedName = sanitizeId(char); | |
| 158 | + const savedInstructions = this.settings.characterInstructions?.[char] || ''; | |
| 159 | + | |
| 160 | + const instructionBlock = document.createElement('div'); | |
| 161 | + const label = document.createElement('label'); | |
| 162 | + const textArea = document.createElement('textarea'); | |
| 163 | + instructionBlock.appendChild(label); | |
| 164 | + instructionBlock.appendChild(textArea); | |
| 165 | + instructionBlock.className = 'character-instructions'; | |
| 166 | + label.setAttribute('for', `openai_char_${sanitizedName}`); | |
| 167 | + label.innerText = `${char}:`; | |
| 168 | + textArea.id = `openai_char_${sanitizedName}`; | |
| 169 | + textArea.placeholder = 'e.g., "Speak cheerfully and energetically"'; | |
| 170 | + textArea.className = 'textarea_compact autoSetHeight'; | |
| 171 | + textArea.value = savedInstructions; | |
| 172 | + textArea.addEventListener('input', () => { | |
| 173 | + this.saveCharacterInstructions(char, textArea.value); | |
| 174 | + }); | |
| 175 | + | |
| 176 | + $('#openai-character-instructions').append(instructionBlock); | |
| 177 | + } | |
| 178 | + } | |
| 179 | + | |
| 180 | + saveCharacterInstructions(characterName, instructions) { | |
| 181 | + if (!this.settings.characterInstructions) { | |
| 182 | + this.settings.characterInstructions = {}; | |
| 183 | + } | |
| 184 | + this.settings.characterInstructions[characterName] = instructions; | |
| 90 | 185 | saveTtsProviderSettings(); |
| 91 | 186 | } |
| 92 | 187 | |
| @@ -112,8 +207,8 @@ class OpenAITtsProvider { | ||
| 112 | 207 | return voice; |
| 113 | 208 | } |
| 114 | 209 | |
| 115 | 210 | async generateTts(text, voiceId, characterName = null) { |
| 116 | 211 | const response = await this.fetchTtsGeneration(text, voiceId, characterName); |
| 117 | 212 | return response; |
| 118 | 213 | } |
| 119 | 214 | |
| @@ -125,17 +220,27 @@ class OpenAITtsProvider { | ||
| 125 | 220 | return; |
| 126 | 221 | } |
| 127 | 222 | |
| 128 | 223 | async fetchTtsGeneration(inputText, voiceId, characterName = null) { |
| 129 | 224 | console.info(`Generating new TTS for voice_id ${voiceId}`); |
| 225 | + | |
| 226 | + const requestBody = { | |
| 227 | + 'text': inputText, | |
| 228 | + 'voice': voiceId, | |
| 229 | + 'model': this.settings.model, | |
| 230 | + 'speed': this.settings.speed, | |
| 231 | + }; | |
| 232 | + | |
| 233 | + if (this.settings.model === 'gpt-4o-mini-tts' && characterName) { | |
| 234 | + const instructions = this.settings.characterInstructions?.[characterName]; | |
| 235 | + if (instructions && instructions.trim()) { | |
| 236 | + requestBody.instructions = instructions; | |
| 237 | + } | |
| 238 | + } | |
| 239 | + | |
| 130 | 240 | const response = await fetch('/api/openai/generate-voice', { |
| 131 | 241 | method: 'POST', |
| 132 | 242 | headers: getRequestHeaders(), |
| 133 | 243 | body: JSON.stringify({requestBody), |
| 134 | - 'text': inputText, | |
| 135 | - 'voice': voiceId, | |
| 136 | - 'model': this.settings.model, | |
| 137 | - 'speed': this.settings.speed, | |
| 138 | - }), | |
| 139 | 244 | }); |
| 140 | 245 | |
| 141 | 246 | if (!response.ok) { |
| @@ -1,3 +1,6 @@ | ||
| 1 | +@import './css/minimax-tts.css'; | |
| 2 | +@import './css/openai-tts.css'; | |
| 3 | + | |
| 1 | 4 | .voice_preview { |
| 2 | 5 | margin: 0.25rem 0.5rem; |
| 3 | 6 | display: flex; |
| @@ -125,5 +128,3 @@ | ||
| 125 | 128 | #at-status_info { |
| 126 | 129 | color: lightgreen; |
| 127 | 130 | } |
| 128 | - | |
| 129 | -@import './css/minimax-tts.css'; | |
| @@ -271,19 +271,27 @@ router.post('/generate-voice', async (request, response) => { | ||
| 271 | 271 | return response.sendStatus(400); |
| 272 | 272 | } |
| 273 | 273 | |
| 274 | + const requestBody = { | |
| 275 | + input: request.body.text, | |
| 276 | + response_format: 'mp3', | |
| 277 | + voice: request.body.voice ?? 'alloy', | |
| 278 | + speed: request.body.speed ?? 1, | |
| 279 | + model: request.body.model ?? 'tts-1', | |
| 280 | + }; | |
| 281 | + | |
| 282 | + if (request.body.instructions) { | |
| 283 | + requestBody.instructions = request.body.instructions; | |
| 284 | + } | |
| 285 | + | |
| 286 | + console.debug('OpenAI TTS request', requestBody); | |
| 287 | + | |
| 274 | 288 | const result = await fetch('https://api.openai.com/v1/audio/speech', { |
| 275 | 289 | method: 'POST', |
| 276 | 290 | headers: { |
| 277 | 291 | 'Content-Type': 'application/json', |
| 278 | 292 | Authorization: `Bearer ${key}`, |
| 279 | 293 | }, |
| 280 | 294 | body: JSON.stringify({requestBody), |
| 281 | - input: request.body.text, | |
| 282 | - response_format: 'mp3', | |
| 283 | - voice: request.body.voice ?? 'alloy', | |
| 284 | - speed: request.body.speed ?? 1, | |
| 285 | - model: request.body.model ?? 'tts-1', | |
| 286 | - }), | |
| 287 | 295 | }); |
| 288 | 296 | |
| 289 | 297 | if (!result.ok) { |