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>

894f3e986ebc1b52d76cf2e69041f5f19e148535

Ni-co-la-s <96898279+Ni-co-la-s@users.noreply.github.com>

Signed
5 files changed, +146 -21Ignore whitespace
public/scripts/extensions/tts/css/openai-tts.css+11 -0
@@ -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}
public/scripts/extensions/tts/index.js+2 -2
@@ -476,7 +476,7 @@ async function tts(text, voiceId, char) {
476 await addAudioJob(response, char);476 await addAudioJob(response, char);
477 }477 }
478478
479 let response = await ttsProvider.generateTts(text, voiceId);479 let response = await ttsProvider.generateTts(text, voiceId, char);
480480
481 // If async generator, process every chunk as it comes in481 // 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}
10091009
1010function sanitizeId(input) {1010export 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, '');
10131013
public/scripts/extensions/tts/openai.js+115 -10
@@ -1,15 +1,18 @@
1import { getRequestHeaders } from '../../../script.js';1import { getRequestHeaders } from '../../../script.js';
2import { saveTtsProviderSettings } from './index.js';2import { saveTtsProviderSettings, sanitizeId } from './index.js';
33
4export { OpenAITtsProvider };4export { OpenAITtsProvider };
55
6class OpenAITtsProvider {6class 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 ];
1518
@@ -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 };
2731
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);
8085
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 }
8494
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 dynamically115 // 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 }
92187
@@ -112,8 +207,8 @@ class OpenAITtsProvider {
112 return voice;207 return voice;
113 }208 }
114209
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 }
119214
@@ -125,17 +220,27 @@ class OpenAITtsProvider {
125 return;220 return;
126 }221 }
127222
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 });
140245
141 if (!response.ok) {246 if (!response.ok) {
public/scripts/extensions/tts/style.css+3 -2
@@ -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';
src/endpoints/openai.js+15 -7
@@ -271,19 +271,27 @@ router.post('/generate-voice', async (request, response) => {
271 return response.sendStatus(400);271 return response.sendStatus(400);
272 }272 }
273273
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 });
288296
289 if (!result.ok) {297 if (!result.ok) {