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 | await addAudioJob(response, char); | 476 | await addAudioJob(response, char); |
| 477 | } | 477 | } |
| 478 | 478 | ||
| 479 | let response = await ttsProvider.generateTts(text, voiceId); | 479 | let response = await ttsProvider.generateTts(text, voiceId, char); |
| 480 | 480 | ||
| 481 | // If async generator, process every chunk as it comes in | 481 | // If async generator, process every chunk as it comes in |
| 482 | if (typeof response[Symbol.asyncIterator] === 'function') { | 482 | if (typeof response[Symbol.asyncIterator] === 'function') { |
| @@ -1007,7 +1007,7 @@ function getCharacters(unrestricted) { | |||
| 1007 | return characters.filter(onlyUnique); | 1007 | return characters.filter(onlyUnique); |
| 1008 | } | 1008 | } |
| 1009 | 1009 | ||
| 1010 | function sanitizeId(input) { | 1010 | export function sanitizeId(input) { |
| 1011 | // Remove any non-alphanumeric characters except underscore (_) and hyphen (-) | 1011 | // Remove any non-alphanumeric characters except underscore (_) and hyphen (-) |
| 1012 | let sanitized = encodeURIComponent(input).replace(/[^a-zA-Z0-9-_]/g, ''); | 1012 | let sanitized = encodeURIComponent(input).replace(/[^a-zA-Z0-9-_]/g, ''); |
| 1013 | 1013 | ||
| @@ -1,15 +1,18 @@ | |||
| 1 | import { getRequestHeaders } from '../../../script.js'; | 1 | import { getRequestHeaders } from '../../../script.js'; |
| 2 | import { saveTtsProviderSettings } from './index.js'; | 2 | import { saveTtsProviderSettings, sanitizeId } from './index.js'; |
| 3 | 3 | ||
| 4 | export { OpenAITtsProvider }; | 4 | export { OpenAITtsProvider }; |
| 5 | 5 | ||
| 6 | class OpenAITtsProvider { | 6 | class OpenAITtsProvider { |
| 7 | static voices = [ | 7 | static voices = [ |
| 8 | { name: 'Alloy', voice_id: 'alloy', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/alloy.wav' }, | 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 | { name: 'Echo', voice_id: 'echo', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/echo.wav' }, | 11 | { name: 'Echo', voice_id: 'echo', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/echo.wav' }, |
| 10 | { name: 'Fable', voice_id: 'fable', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/fable.wav' }, | 12 | { name: 'Fable', voice_id: 'fable', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/fable.wav' }, |
| 11 | { name: 'Onyx', voice_id: 'onyx', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/onyx.wav' }, | 13 | { name: 'Onyx', voice_id: 'onyx', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/onyx.wav' }, |
| 12 | { name: 'Nova', voice_id: 'nova', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/nova.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' }, | ||
| 13 | { name: 'Shimmer', voice_id: 'shimmer', lang: 'en-US', preview_url: 'https://cdn.openai.com/API/docs/audio/shimmer.wav' }, | 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 | customVoices: [], | 26 | customVoices: [], |
| 24 | model: 'tts-1', | 27 | model: 'tts-1', |
| 25 | speed: 1, | 28 | speed: 1, |
| 29 | characterInstructions: {}, | ||
| 26 | }; | 30 | }; |
| 27 | 31 | ||
| 28 | get settingsHtml() { | 32 | get settingsHtml() { |
| @@ -35,6 +39,7 @@ class OpenAITtsProvider { | |||
| 35 | <optgroup label="Latest"> | 39 | <optgroup label="Latest"> |
| 36 | <option value="tts-1">tts-1</option> | 40 | <option value="tts-1">tts-1</option> |
| 37 | <option value="tts-1-hd">tts-1-hd</option> | 41 | <option value="tts-1-hd">tts-1-hd</option> |
| 42 | <option value="gpt-4o-mini-tts">gpt-4o-mini-tts</option> | ||
| 38 | </optgroup> | 43 | </optgroup> |
| 39 | <optgroup label="Snapshots"> | 44 | <optgroup label="Snapshots"> |
| 40 | <option value="tts-1-1106">tts-1-1106</option> | 45 | <option value="tts-1-1106">tts-1-1106</option> |
| @@ -79,14 +84,104 @@ class OpenAITtsProvider { | |||
| 79 | $('#openai-tts-speed-output').text(this.settings.speed); | 84 | $('#openai-tts-speed-output').text(this.settings.speed); |
| 80 | 85 | ||
| 81 | await this.checkReady(); | 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 | console.debug('OpenAI TTS: Settings loaded'); | 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 | onSettingsChange() { | 114 | onSettingsChange() { |
| 86 | // Update dynamically | 115 | // Update dynamically |
| 87 | this.settings.model = String($('#openai-tts-model').find(':selected').val()); | 116 | this.settings.model = String($('#openai-tts-model').find(':selected').val()); |
| 88 | this.settings.speed = Number($('#openai-tts-speed').val()); | 117 | this.settings.speed = Number($('#openai-tts-speed').val()); |
| 89 | $('#openai-tts-speed-output').text(this.settings.speed); | 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 | saveTtsProviderSettings(); | 185 | saveTtsProviderSettings(); |
| 91 | } | 186 | } |
| 92 | 187 | ||
| @@ -112,8 +207,8 @@ class OpenAITtsProvider { | |||
| 112 | return voice; | 207 | return voice; |
| 113 | } | 208 | } |
| 114 | 209 | ||
| 115 | async generateTts(text, voiceId) { | 210 | async generateTts(text, voiceId, characterName = null) { |
| 116 | const response = await this.fetchTtsGeneration(text, voiceId); | 211 | const response = await this.fetchTtsGeneration(text, voiceId, characterName); |
| 117 | return response; | 212 | return response; |
| 118 | } | 213 | } |
| 119 | 214 | ||
| @@ -125,17 +220,27 @@ class OpenAITtsProvider { | |||
| 125 | return; | 220 | return; |
| 126 | } | 221 | } |
| 127 | 222 | ||
| 128 | async fetchTtsGeneration(inputText, voiceId) { | 223 | async fetchTtsGeneration(inputText, voiceId, characterName = null) { |
| 129 | console.info(`Generating new TTS for voice_id ${voiceId}`); | 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 | const response = await fetch('/api/openai/generate-voice', { | 240 | const response = await fetch('/api/openai/generate-voice', { |
| 131 | method: 'POST', | 241 | method: 'POST', |
| 132 | headers: getRequestHeaders(), | 242 | headers: getRequestHeaders(), |
| 133 | body: JSON.stringify({ | 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 | if (!response.ok) { | 246 | if (!response.ok) { |
| @@ -1,3 +1,6 @@ | |||
| 1 | @import './css/minimax-tts.css'; | ||
| 2 | @import './css/openai-tts.css'; | ||
| 3 | |||
| 1 | .voice_preview { | 4 | .voice_preview { |
| 2 | margin: 0.25rem 0.5rem; | 5 | margin: 0.25rem 0.5rem; |
| 3 | display: flex; | 6 | display: flex; |
| @@ -125,5 +128,3 @@ | |||
| 125 | #at-status_info { | 128 | #at-status_info { |
| 126 | color: lightgreen; | 129 | color: lightgreen; |
| 127 | } | 130 | } |
| 128 | |||
| 129 | @import './css/minimax-tts.css'; | ||
| @@ -271,19 +271,27 @@ router.post('/generate-voice', async (request, response) => { | |||
| 271 | return response.sendStatus(400); | 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 | const result = await fetch('https://api.openai.com/v1/audio/speech', { | 288 | const result = await fetch('https://api.openai.com/v1/audio/speech', { |
| 275 | method: 'POST', | 289 | method: 'POST', |
| 276 | headers: { | 290 | headers: { |
| 277 | 'Content-Type': 'application/json', | 291 | 'Content-Type': 'application/json', |
| 278 | Authorization: `Bearer ${key}`, | 292 | Authorization: `Bearer ${key}`, |
| 279 | }, | 293 | }, |
| 280 | body: JSON.stringify({ | 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 | if (!result.ok) { | 297 | if (!result.ok) { |