| 1 | import { getRequestHeaders } from '../../../script.js'; |
| 2 | import { oai_settings } from '../../openai.js'; |
| 3 | import { isValidUrl } from '../../utils.js'; |
| 4 | import { getPreviewString, saveTtsProviderSettings } from './index.js'; |
| 5 | |
| 6 | |
| 7 | export class GoogleNativeTtsProvider { |
| 8 | settings; |
| 9 | voices = []; |
| 10 | separator = ' . '; |
| 11 | audioElement = document.createElement('audio'); |
| 12 | |
| 13 | defaultSettings = { |
| 14 | voiceMap: {}, |
| 15 | model: 'gemini-2.5-flash-preview-tts', |
| 16 | apiType: 'makersuite', |
| 17 | }; |
| 18 | |
| 19 | get settingsHtml() { |
| 20 | return ` |
| 21 | <small>Hint: Save an API key in the Google AI Studio/Vertex AI connection settings</small> |
| 22 | <div id="google-native-tts-settings"> |
| 23 | <div> |
| 24 | <label for="google-tts-api-type">API Type:</label> |
| 25 | <select id="google-tts-api-type"> |
| 26 | <option value="makersuite">Google AI Studio (MakerSuite)</option> |
| 27 | <option value="vertexai" disabled>Google Vertex AI (unsupported)</option> |
| 28 | </select> |
| 29 | </div> |
| 30 | <div> |
| 31 | <label for="google-tts-model">Model:</label> |
| 32 | <select id="google-tts-model"> |
| 33 | <option value="gemini-2.5-flash-preview-tts">Gemini 2.5 Flash Preview TTS</option> |
| 34 | <option value="gemini-2.5-pro-preview-tts">Gemini 2.5 Pro Preview TTS</option> |
| 35 | </select> |
| 36 | </div> |
| 37 | </div>`; |
| 38 | } |
| 39 | |
| 40 | async loadSettings(settings) { |
| 41 | if (Object.keys(settings).length === 0) { |
| 42 | console.info('Using default Google TTS Provider settings'); |
| 43 | } |
| 44 | |
| 45 | this.settings = { ...this.defaultSettings, ...settings }; |
| 46 | |
| 47 | $('#google-tts-api-type').val(this.settings.apiType); |
| 48 | $('#google-tts-model').val(this.settings.model); |
| 49 | |
| 50 | $('#google-tts-api-type, #google-tts-model').on('change', () => this.onSettingsChange()); |
| 51 | |
| 52 | try { |
| 53 | await this.checkReady(); |
| 54 | console.debug('Google TTS: Settings loaded'); |
| 55 | } catch (err) { |
| 56 | console.warn('Google TTS: Settings loaded, but not ready.', err.message); |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | onSettingsChange() { |
| 61 | this.settings.apiType = $('#google-tts-api-type').val(); |
| 62 | this.settings.model = $('#google-tts-model').val(); |
| 63 | |
| 64 | this.voices = []; // Reset voices cache so it re-fetches |
| 65 | saveTtsProviderSettings(); |
| 66 | } |
| 67 | |
| 68 | async checkReady() { |
| 69 | await this.fetchTtsVoiceObjects(); |
| 70 | } |
| 71 | |
| 72 | async onRefreshClick() { |
| 73 | await this.checkReady(); |
| 74 | } |
| 75 | |
| 76 | async getVoice(voiceName) { |
| 77 | if (this.voices.length === 0) { |
| 78 | this.voices = await this.fetchTtsVoiceObjects(); |
| 79 | } |
| 80 | |
| 81 | const match = this.voices.find(voice => voice.name === voiceName || voice.voice_id === voiceName); |
| 82 | |
| 83 | if (!match) { |
| 84 | throw `TTS Voice name ${voiceName} not found`; |
| 85 | } |
| 86 | return match; |
| 87 | } |
| 88 | |
| 89 | async generateTts(text, voiceId) { |
| 90 | return await this.fetchNativeTtsGeneration(text, voiceId); |
| 91 | } |
| 92 | |
| 93 | async fetchTtsVoiceObjects() { |
| 94 | try { |
| 95 | const response = await fetch('/api/google/list-native-voices', { |
| 96 | method: 'POST', |
| 97 | headers: getRequestHeaders(), |
| 98 | body: JSON.stringify({}), |
| 99 | }); |
| 100 | |
| 101 | if (!response.ok) { |
| 102 | let errorMessage = `HTTP ${response.status}: ${response.statusText}`; |
| 103 | |
| 104 | try { |
| 105 | const errorJson = await response.json(); |
| 106 | if (errorJson.error) { |
| 107 | errorMessage = errorJson.error; |
| 108 | } |
| 109 | } catch (parseError) { |
| 110 | // Response isn't valid JSON, use the HTTP error message |
| 111 | console.debug('Error response is not JSON:', parseError.message); |
| 112 | } |
| 113 | |
| 114 | throw new Error(errorMessage); |
| 115 | } |
| 116 | |
| 117 | const responseJson = await response.json(); |
| 118 | |
| 119 | if (!responseJson.voices || !Array.isArray(responseJson.voices)) { |
| 120 | throw new Error('Invalid response format: voices array not found'); |
| 121 | } |
| 122 | |
| 123 | this.voices = responseJson.voices; |
| 124 | console.info(`Google TTS: Loaded ${this.voices.length} voices`); |
| 125 | |
| 126 | return this.voices; |
| 127 | } catch (error) { |
| 128 | console.error('Failed to fetch Google TTS voices:', error); |
| 129 | throw error; |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | async previewTtsVoice(id) { |
| 134 | this.audioElement.pause(); |
| 135 | this.audioElement.currentTime = 0; |
| 136 | |
| 137 | try { |
| 138 | const voice = await this.getVoice(id); |
| 139 | const text = getPreviewString(voice.lang || 'en-US'); |
| 140 | |
| 141 | const response = await this.fetchNativeTtsGeneration(text, id); |
| 142 | |
| 143 | if (!response.ok) { |
| 144 | // Error is handled inside the fetch function, but we still need to stop here |
| 145 | return; |
| 146 | } |
| 147 | |
| 148 | const audioBlob = await response.blob(); |
| 149 | const url = URL.createObjectURL(audioBlob); |
| 150 | this.audioElement.src = url; |
| 151 | this.audioElement.play(); |
| 152 | this.audioElement.onended = () => URL.revokeObjectURL(url); |
| 153 | } catch (error) { |
| 154 | console.error('TTS Preview Error:', error); |
| 155 | toastr.error(`Could not generate preview: ${error.message}`); |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | async fetchNativeTtsGeneration(text, voiceId) { |
| 160 | console.info(`Generating native Google TTS for voice_id ${voiceId}`); |
| 161 | const useReverseProxy = oai_settings.reverse_proxy && isValidUrl(oai_settings.reverse_proxy); |
| 162 | |
| 163 | const response = await fetch('/api/google/generate-native-tts', { |
| 164 | method: 'POST', |
| 165 | headers: getRequestHeaders(), |
| 166 | body: JSON.stringify({ |
| 167 | text: text, |
| 168 | voice: voiceId, |
| 169 | model: this.settings.model, |
| 170 | api: this.settings.apiType, |
| 171 | reverse_proxy: useReverseProxy ? oai_settings.reverse_proxy : '', |
| 172 | proxy_password: useReverseProxy ? oai_settings.proxy_password : '', |
| 173 | vertexai_auth_mode: oai_settings.vertexai_auth_mode, |
| 174 | vertexai_region: oai_settings.vertexai_region, |
| 175 | vertexai_express_project_id: oai_settings.vertexai_express_project_id, |
| 176 | }), |
| 177 | }); |
| 178 | |
| 179 | if (!response.ok) { |
| 180 | let errorMessage = `HTTP ${response.status}: ${response.statusText}`; |
| 181 | try { |
| 182 | const errorJson = await response.json(); |
| 183 | if (errorJson.error) { |
| 184 | errorMessage = errorJson.error; |
| 185 | } |
| 186 | } catch { |
| 187 | // Not a JSON response, do nothing and keep the original http error |
| 188 | } |
| 189 | throw new Error(errorMessage); |
| 190 | } |
| 191 | return response; |
| 192 | } |
| 193 | } |