Blame Raw
· · · 455 lines (21.0 KB)
0 contributors
1import { event_types, eventSource, getRequestHeaders } from '../../../script.js';
2import { SECRET_KEYS, secret_state } from '../../secrets.js';
3import { getPreviewString, saveTtsProviderSettings, initVoiceMap } from './index.js';
4
5export { ElectronHubTtsProvider };
6
7class ElectronHubTtsProvider {
8 settings;
9 voices = [];
10 models = [];
11 separator = ' . ';
12 audioElement = document.createElement('audio');
13
14 defaultSettings = {
15 voiceMap: {},
16 model: 'tts-1',
17 speed: 1,
18 temperature: 1,
19 top_p: 1,
20 // GPT-4o Mini TTS
21 instructions: '',
22 // Dia
23 speaker_transcript: '',
24 cfg_filter_top_k: 25,
25 cfg_scale: 3,
26 // Microsoft TTS
27 speech_rate: 0,
28 pitch_adjustment: 0,
29 emotional_style: '',
30 };
31
32 get settingsHtml() {
33 let html = `
34 <div>Electron Hub unified TTS API.</div>
35 <div class="flex-container alignItemsCenter">
36 <div class="flex1"></div>
37 <div id="electronhub_tts_key" class="menu_button menu_button_icon manage-api-keys" data-key="api_key_electronhub">
38 <i class="fa-solid fa-key"></i>
39 <span>API Key</span>
40 </div>
41 </div>
42 <div class="flex-container flexGap10 wrap">
43 <div class="flex1">
44 <label for="electronhub_tts_model">Model</label>
45 <select id="electronhub_tts_model" class="text_pole"></select>
46 </div>
47 <div>
48 <label for="electronhub_tts_speed">Speed <span id="electronhub_tts_speed_output"></span></label>
49 <input type="range" id="electronhub_tts_speed" value="1" min="0.25" max="4" step="0.05">
50 </div>
51 <div>
52 <label for="electronhub_tts_temperature">Temperature</label>
53 <input id="electronhub_tts_temperature" class="text_pole" type="number" min="0" max="2" step="0.1" value="1" />
54 </div>
55 <div id="electronhub_block_top_p" style="display:none;">
56 <label for="electronhub_tts_top_p">Top-p</label>
57 <input id="electronhub_tts_top_p" class="text_pole" type="number" min="0" max="1" step="0.01" value="1" />
58 </div>
59 </div>
60
61 <div id="electronhub_block_instructions" style="display:none;">
62 <label for="electronhub_tts_instructions">Instructions (GPT-4o Mini TTS):</label>
63 <textarea id="electronhub_tts_instructions" class="textarea_compact autoSetHeight" placeholder="e.g., 'Speak cheerfully and energetically'"></textarea>
64 </div>
65
66 <div id="electronhub_block_dia" style="display:none;">
67 <label for="electronhub_tts_speaker_transcript">Speaker transcript (Dia):</label>
68 <textarea id="electronhub_tts_speaker_transcript" class="textarea_compact autoSetHeight" maxlength="1000"></textarea>
69 <label for="electronhub_tts_cfg_scale">CFG scale (1-5):</label>
70 <input id="electronhub_tts_cfg_scale" type="number" min="1" max="5" step="1" />
71 <label for="electronhub_tts_cfg_topk">CFG filter top_k (15-50):</label>
72 <input id="electronhub_tts_cfg_topk" type="number" min="15" max="50" step="1" />
73 </div>
74
75 <div id="electronhub_block_msft" style="display:none;">
76 <div class="flex-container flexGap10 wrap">
77 <div>
78 <label for="electronhub_tts_speech_rate">Speech rate (-100..100)</label>
79 <input id="electronhub_tts_speech_rate" class="text_pole" type="number" min="-100" max="100" step="1" style="width:120px;" />
80 </div>
81 <div>
82 <label for="electronhub_tts_pitch_adjustment">Pitch adjustment (-100..100)</label>
83 <input id="electronhub_tts_pitch_adjustment" class="text_pole" type="number" min="-100" max="100" step="1" style="width:120px;" />
84 </div>
85 </div>
86 <div class="flex-container flexGap10">
87 <div class="flex1">
88 <label for="electronhub_tts_emotional_style">Emotional style</label>
89 <input id="electronhub_tts_emotional_style" class="text_pole" type="text" placeholder="cheerful, sad, angry, gentle..." />
90 </div>
91 </div>
92 </div>
93
94 <div id="electronhub_dynamic_params" class="flex-container flexGap10 wrap" style="display:none;"></div>`;
95 return html;
96 }
97
98 constructor() {
99 this.handler = async function (/** @type {string} */ key) {
100 if (key !== SECRET_KEYS.ELECTRONHUB) return;
101 $('#electronhub_tts_key').toggleClass('success', !!secret_state[SECRET_KEYS.ELECTRONHUB]);
102 await this.onRefreshClick();
103 }.bind(this);
104 }
105
106 dispose() {
107 [event_types.SECRET_WRITTEN, event_types.SECRET_DELETED, event_types.SECRET_ROTATED].forEach(event => {
108 eventSource.removeListener(event, this.handler);
109 });
110 }
111
112 async loadSettings(settings) {
113 if (Object.keys(settings).length == 0) {
114 console.info('Using default Electron Hub TTS settings');
115 }
116
117 this.settings = { ...this.defaultSettings, ...settings };
118
119 await this.loadModels();
120 this.populateModelSelect();
121
122 $('#electronhub_tts_model').val(this.settings.model);
123 $('#electronhub_tts_model').on('change', () => { this.onSettingsChange(); });
124
125 $('#electronhub_tts_speed').val(this.settings.speed);
126 $('#electronhub_tts_speed_output').text(this.settings.speed);
127 $('#electronhub_tts_speed').on('input', () => { this.onSettingsChange(); });
128
129 $('#electronhub_tts_temperature').val(this.settings.temperature);
130 $('#electronhub_tts_temperature').on('input', () => { this.onSettingsChange(); });
131
132 $('#electronhub_tts_top_p').val(this.settings.top_p);
133 $('#electronhub_tts_top_p').on('input', () => { this.onSettingsChange(); });
134
135 $('#electronhub_tts_instructions').val(this.settings.instructions);
136 $('#electronhub_tts_instructions').on('input', () => { this.onSettingsChange(); });
137
138 $('#electronhub_tts_speaker_transcript').val(this.settings.speaker_transcript);
139 $('#electronhub_tts_speaker_transcript').on('input', () => { this.onSettingsChange(); });
140 $('#electronhub_tts_cfg_scale').val(this.settings.cfg_scale);
141 $('#electronhub_tts_cfg_scale').on('input', () => { this.onSettingsChange(); });
142 $('#electronhub_tts_cfg_topk').val(this.settings.cfg_filter_top_k);
143 $('#electronhub_tts_cfg_topk').on('input', () => { this.onSettingsChange(); });
144
145 $('#electronhub_tts_speech_rate').val(this.settings.speech_rate);
146 $('#electronhub_tts_speech_rate').on('input', () => { this.onSettingsChange(); });
147 $('#electronhub_tts_pitch_adjustment').val(this.settings.pitch_adjustment);
148 $('#electronhub_tts_pitch_adjustment').on('input', () => { this.onSettingsChange(); });
149 $('#electronhub_tts_emotional_style').val(this.settings.emotional_style);
150 $('#electronhub_tts_emotional_style').on('input', () => { this.onSettingsChange(); });
151
152 $('#electronhub_tts_key').toggleClass('success', !!secret_state[SECRET_KEYS.ELECTRONHUB]);
153 [event_types.SECRET_WRITTEN, event_types.SECRET_DELETED, event_types.SECRET_ROTATED].forEach(event => {
154 eventSource.on(event, this.handler);
155 });
156
157 await this.checkReady();
158 this.updateConditionalBlocks();
159 this.renderDynamicParams();
160 console.debug('Electron Hub TTS: Settings loaded');
161 }
162
163 async onSettingsChange() {
164 const previousModel = this.settings.model;
165 this.settings.model = String($('#electronhub_tts_model').find(':selected').val() || this.settings.model);
166 this.settings.speed = Number($('#electronhub_tts_speed').val());
167 $('#electronhub_tts_speed_output').text(this.settings.speed);
168 this.settings.temperature = Number($('#electronhub_tts_temperature').val());
169 this.settings.top_p = Number($('#electronhub_tts_top_p').val());
170 this.settings.instructions = String($('#electronhub_tts_instructions').val() || '');
171 this.settings.speaker_transcript = String($('#electronhub_tts_speaker_transcript').val() || '');
172 this.settings.cfg_scale = Number($('#electronhub_tts_cfg_scale').val());
173 this.settings.cfg_filter_top_k = Number($('#electronhub_tts_cfg_topk').val());
174 this.settings.speech_rate = Number($('#electronhub_tts_speech_rate').val());
175 this.settings.pitch_adjustment = Number($('#electronhub_tts_pitch_adjustment').val());
176 this.settings.emotional_style = String($('#electronhub_tts_emotional_style').val() || '');
177 this.updateConditionalBlocks();
178 this.renderDynamicParams();
179 saveTtsProviderSettings();
180 if (previousModel !== this.settings.model) {
181 this.voices = await this.fetchTtsVoiceObjects();
182 await initVoiceMap();
183 }
184 }
185
186 async loadModels() {
187 try {
188 const response = await fetch('/api/openai/electronhub/models', {
189 method: 'POST',
190 headers: getRequestHeaders({ omitContentType: true }),
191 });
192 if (!response.ok) {
193 throw new Error(`HTTP ${response.status}: ${await response.text()}`);
194 }
195 /** @type {Array<any>} */
196 const data = await response.json();
197 const allModels = Array.isArray(data) ? data : [];
198 const ttsModels = allModels.filter(m => {
199 const eps = Array.isArray(m?.endpoints) ? m.endpoints : [];
200 return eps.some(ep => {
201 if (typeof ep !== 'string') return false;
202 return ep === '/v1/audio/speech' || ep.endsWith('/audio/speech') || ep === 'audio/speech';
203 });
204 });
205
206 this.models = ttsModels;
207
208 if (this.models.length > 0 && !this.models.find(m => m.id === this.settings.model)) {
209 this.settings.model = this.models[0].id;
210 saveTtsProviderSettings();
211 }
212 } catch (err) {
213 console.warn('Electron Hub models fetch failed', err);
214 this.models = [];
215 }
216 }
217
218 populateModelSelect() {
219 const select = $('#electronhub_tts_model');
220 select.empty();
221 const groups = this.groupByVendor(this.models);
222 for (const [vendor, models] of groups.entries()) {
223 const optgroup = document.createElement('optgroup');
224 optgroup.label = vendor;
225 for (const m of models) {
226 const opt = document.createElement('option');
227 opt.value = m.id;
228 opt.text = m.name || m.id;
229 optgroup.appendChild(opt);
230 }
231 select.append(optgroup);
232 }
233
234 if (this.models.find(x => x.id === this.settings.model)) {
235 select.val(this.settings.model);
236 }
237 }
238
239 /**
240 * Group models by vendor prefix from name before ':'
241 * @param {Array<any>} array
242 * @returns {Map<string, any[]>}
243 */
244 groupByVendor(array) {
245 return array.reduce((acc, curr) => {
246 const name = String(curr?.name || curr?.id || 'Other');
247 const vendor = name.split(':')[0].trim() || 'Other';
248 if (!acc.has(vendor)) acc.set(vendor, []);
249 acc.get(vendor).push(curr);
250 return acc;
251 }, new Map());
252 }
253
254 updateConditionalBlocks() {
255 const modelId = this.settings.model;
256 const model = this.models.find(m => m.id === modelId);
257 const params = model?.parameters || {};
258 const vendorName = String(model?.name || '').split(':')[0].trim().toLowerCase();
259
260 const hasInstructions = 'instructions' in params || modelId === 'gpt-4o-mini-tts';
261 const hasDia = 'speaker_transcript' in params || 'cfg_scale' in params || 'cfg_filter_top_k' in params || modelId.includes('dia');
262
263 const hasMsft = 'speech_rate' in params || 'pitch_adjustment' in params || 'emotional_style' in params || vendorName === 'microsoft' || modelId === 'microsoft-tts';
264 const hasTopP = 'top_p' in params;
265
266 $('#electronhub_block_instructions').toggle(!!hasInstructions);
267 $('#electronhub_block_dia').toggle(!!hasDia);
268 $('#electronhub_block_msft').toggle(!!hasMsft);
269 $('#electronhub_block_top_p').toggle(!!hasTopP);
270 }
271
272 /**
273 * Build UI for additional model parameters dynamically
274 */
275 renderDynamicParams() {
276 const container = $('#electronhub_dynamic_params');
277 container.empty();
278 const model = this.models.find(m => m.id === this.settings.model);
279 const params = model?.parameters || {};
280 const modelHasVoices = Array.isArray(model?.voices) && model.voices.length > 0;
281 const exclude = new Set(['input', 'response_format', 'model', 'speed', 'temperature', 'top_p', 'instructions', 'speaker_transcript', 'cfg_scale', 'cfg_filter_top_k', 'speech_rate', 'pitch_adjustment', 'emotional_style']);
282 if (modelHasVoices) exclude.add('voice');
283
284 const entries = Object.entries(params).filter(([k]) => !exclude.has(k));
285 container.toggle(entries.length > 0);
286 if (entries.length === 0) return;
287
288 for (const [key, spec] of entries) {
289 const nice = key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
290 const type = String(spec?.type || 'string');
291 const id = `electronhub_dyn_${key.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
292
293 if (Array.isArray(spec?.enum) && spec.enum.length) {
294 const select = $(`<div><label for="${id}">${nice}</label><select id="${id}" class="text_pole"></select></div>`);
295 container.append(select);
296 const el = select.find('select');
297 for (const opt of spec.enum) el.append(new Option(String(opt), String(opt)));
298 const val = this.settings[key] ?? spec.default ?? spec.enum[0];
299 el.val(String(val));
300 el.on('change', () => { this.settings[key] = String(el.val() || ''); saveTtsProviderSettings(); });
301 continue;
302 }
303
304 if (type === 'boolean') {
305 const block = $(`<label class="checkbox_label" for="${id}"><input type="checkbox" id="${id}"> <small>${nice}</small></label>`);
306 container.append(block);
307 const el = block.find('input');
308 el.prop('checked', !!(this.settings[key] ?? spec.default ?? false));
309 el.on('change', () => { this.settings[key] = !!el.is(':checked'); saveTtsProviderSettings(); });
310 continue;
311 }
312
313 if (type === 'number' || type === 'integer') {
314 const min = spec.minimum ?? undefined;
315 const max = spec.maximum ?? undefined;
316 const step = type === 'integer' ? 1 : (spec.step ?? 0.01);
317 const block = $(`<div><label for="${id}">${nice}${(min != null || max != null) ? ` (${min ?? ''}..${max ?? ''})` : ''}:</label><input id="${id}" type="number" class="text_pole" ${min != null ? `min="${min}"` : ''} ${max != null ? `max="${max}"` : ''} step="${step}"></div>`);
318 container.append(block);
319 const el = block.find('input');
320 const val = this.settings[key] ?? spec.default ?? '';
321 if (val !== '') el.val(val);
322 el.on('input', () => {
323 const raw = el.val();
324 this.settings[key] = (raw === '') ? '' : Number(raw);
325 saveTtsProviderSettings();
326 });
327 continue;
328 }
329
330 const isLong = /instructions|transcript|style|prompt|description/i.test(key);
331 if (isLong) {
332 const block = $(`<div><label for="${id}">${nice}</label><textarea id="${id}" class="textarea_compact autoSetHeight"></textarea></div>`);
333 container.append(block);
334 const el = block.find('textarea');
335 el.val(String(this.settings[key] ?? spec.default ?? ''));
336 el.on('input', () => { this.settings[key] = String(el.val() || ''); saveTtsProviderSettings(); });
337 } else {
338 const block = $(`<div><label for="${id}">${nice}</label><input id="${id}" type="text" class="text_pole" /></div>`);
339 container.append(block);
340 const el = block.find('input');
341 el.val(String(this.settings[key] ?? spec.default ?? ''));
342 el.on('input', () => { this.settings[key] = String(el.val() || ''); saveTtsProviderSettings(); });
343 }
344 }
345 }
346
347 async checkReady() {
348 this.voices = await this.fetchTtsVoiceObjects();
349 }
350
351 async onRefreshClick() {
352 await this.loadModels();
353 this.populateModelSelect();
354 this.voices = await this.fetchTtsVoiceObjects();
355 this.updateConditionalBlocks();
356 this.renderDynamicParams();
357 saveTtsProviderSettings();
358 }
359
360 async getVoice(voiceName) {
361 if (this.voices.length == 0) {
362 this.voices = await this.fetchTtsVoiceObjects();
363 }
364 const match = this.voices.filter(v => v.name == voiceName)[0];
365 if (!match) {
366 throw `TTS Voice name ${voiceName} not found`;
367 }
368 return match;
369 }
370
371 async generateTts(text, voiceId) {
372 const response = await this.fetchTtsGeneration(text, voiceId);
373 return response;
374 }
375
376 async fetchTtsVoiceObjects() {
377 const modelId = this.settings.model;
378 const model = this.models.find(m => m.id === modelId);
379 if (model && Array.isArray(model.voices) && model.voices.length) {
380 return model.voices.map(name => ({ name, voice_id: name, lang: 'en-US' }));
381 }
382 // Fallback to common OpenAI voices
383 const fallback = ['alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer', 'verse'];
384 return fallback.map(name => ({ name, voice_id: name, lang: 'en-US' }));
385 }
386
387 async previewTtsVoice(voiceId) {
388 this.audioElement.pause();
389 this.audioElement.currentTime = 0;
390 const text = getPreviewString('en-US');
391 const response = await this.fetchTtsGeneration(text, voiceId);
392 if (!response.ok) {
393 throw new Error(`HTTP ${response.status}`);
394 }
395 const audio = await response.blob();
396 const url = URL.createObjectURL(audio);
397 this.audioElement.src = url;
398 this.audioElement.play();
399 this.audioElement.onended = () => URL.revokeObjectURL(url);
400 }
401
402 async fetchTtsGeneration(inputText, voiceId) {
403 console.info(`Generating Electron Hub TTS for voice_id ${voiceId}`);
404 const body = {
405 input: inputText,
406 voice: voiceId,
407 speed: this.settings.speed,
408 temperature: this.settings.temperature,
409 model: this.settings.model,
410 };
411
412 const model = (this.settings.model || '').toLowerCase();
413 if (model === 'gpt-4o-mini-tts') {
414 if (this.settings.instructions?.trim()) body.instructions = this.settings.instructions.trim();
415 }
416 if (model.includes('dia')) {
417 if (this.settings.speaker_transcript?.trim()) body.speaker_transcript = this.settings.speaker_transcript.trim();
418 if (Number.isFinite(this.settings.cfg_scale)) body.cfg_scale = Number(this.settings.cfg_scale);
419 if (Number.isFinite(this.settings.cfg_filter_top_k)) body.cfg_filter_top_k = Number(this.settings.cfg_filter_top_k);
420 }
421 if (model.includes('microsoft-tts')) {
422 if (Number.isFinite(this.settings.speech_rate)) body.speech_rate = Number(this.settings.speech_rate);
423 if (Number.isFinite(this.settings.pitch_adjustment)) body.pitch_adjustment = Number(this.settings.pitch_adjustment);
424 if ((this.settings.emotional_style || '').trim()) body.emotional_style = String(this.settings.emotional_style).trim();
425 }
426 if (Number.isFinite(this.settings.top_p)) {
427 body.top_p = Number(this.settings.top_p);
428 }
429
430 // add dynamic params based on schema
431 const modelObj = this.models.find(m => m.id === this.settings.model);
432 const params = modelObj?.parameters || {};
433 const modelHasVoices = Array.isArray(modelObj?.voices) && modelObj.voices.length > 0;
434 const exclude = new Set(['input', 'response_format', 'model', 'speed', 'temperature', 'top_p', 'instructions', 'speaker_transcript', 'cfg_scale', 'cfg_filter_top_k', 'speech_rate', 'pitch_adjustment', 'emotional_style']);
435 if (modelHasVoices) exclude.add('voice');
436 for (const key of Object.keys(params)) {
437 if (exclude.has(key)) continue;
438 const val = this.settings[key];
439 if (val === undefined || val === '') continue;
440 body[key] = val;
441 }
442
443 const response = await fetch('/api/openai/electronhub/generate-voice', {
444 method: 'POST',
445 headers: getRequestHeaders(),
446 body: JSON.stringify(body),
447 });
448
449 if (!response.ok) {
450 throw new Error(`HTTP ${response.status}: ${await response.text()}`);
451 }
452
453 return response;
454 }
455}