| 1 | import { event_types, eventSource, getRequestHeaders } from '../../../script.js'; |
| 2 | import { SECRET_KEYS, secret_state } from '../../secrets.js'; |
| 3 | import { getPreviewString, saveTtsProviderSettings, initVoiceMap } from './index.js'; |
| 4 | |
| 5 | export { ElectronHubTtsProvider }; |
| 6 | |
| 7 | class 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 | } |