Feature: Google Native TTS (#4163) * google native tts impl * Refactor getting google auth data * Bring back status json * Native TTS => Gemini TTS --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

48f18cb74a58b80d214f1cd1c6c93cb571a41853

powercaller <133809462+powercaller@users.noreply.github.com>

Signed
3 files changed, +409 -60Ignore whitespace
public/scripts/extensions/tts/google-native.js+195 -0
@@ -0,0 +1,195 @@
1import { getRequestHeaders } from '../../../script.js';
2import { oai_settings } from '../../openai.js';
3import { isValidUrl } from '../../utils.js';
4import { getPreviewString, saveTtsProviderSettings } from './index.js';
5
6
7export 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">Google Vertex AI</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
128 } catch (error) {
129 console.error('Failed to fetch Google TTS voices:', error);
130 throw error;
131 }
132 }
133
134 async previewTtsVoice(id) {
135 this.audioElement.pause();
136 this.audioElement.currentTime = 0;
137
138 try {
139 const voice = await this.getVoice(id);
140 const text = getPreviewString(voice.lang || 'en-US');
141
142 const response = await this.fetchNativeTtsGeneration(text, id);
143
144 if (!response.ok) {
145 // Error is handled inside the fetch function, but we still need to stop here
146 return;
147 }
148
149 const audioBlob = await response.blob();
150 const url = URL.createObjectURL(audioBlob);
151 this.audioElement.src = url;
152 this.audioElement.play();
153 this.audioElement.onended = () => URL.revokeObjectURL(url);
154
155 } catch (error) {
156 console.error('TTS Preview Error:', error);
157 toastr.error(`Could not generate preview: ${error.message}`);
158 }
159 }
160
161 async fetchNativeTtsGeneration(text, voiceId) {
162 console.info(`Generating native Google TTS for voice_id ${voiceId}`);
163 const useReverseProxy = oai_settings.reverse_proxy && isValidUrl(oai_settings.reverse_proxy);
164
165 const response = await fetch('/api/google/generate-native-tts', {
166 method: 'POST',
167 headers: getRequestHeaders(),
168 body: JSON.stringify({
169 text: text,
170 voice: voiceId,
171 model: this.settings.model,
172 api: this.settings.apiType,
173 reverse_proxy: useReverseProxy ? oai_settings.reverse_proxy : '',
174 proxy_password: useReverseProxy ? oai_settings.proxy_password : '',
175 vertexai_auth_mode: oai_settings.vertexai_auth_mode,
176 vertexai_region: oai_settings.vertexai_region,
177 vertexai_express_project_id: oai_settings.vertexai_express_project_id,
178 }),
179 });
180
181 if (!response.ok) {
182 let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
183 try {
184 const errorJson = await response.json();
185 if (errorJson.error) {
186 errorMessage = errorJson.error;
187 }
188 } catch {
189 // Not a JSON response, do nothing and keep the original http error
190 }
191 throw new Error(errorMessage);
192 }
193 return response;
194 }
195}
public/scripts/extensions/tts/index.js+2 -0
@@ -27,6 +27,7 @@ import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashComm
27import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';27import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
28import { POPUP_TYPE, callGenericPopup } from '../../popup.js';28import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
29import { GoogleTranslateTtsProvider } from './google-translate.js';29import { GoogleTranslateTtsProvider } from './google-translate.js';
30import { GoogleNativeTtsProvider } from './google-native.js';
30import { ChatterboxTtsProvider } from './chatterbox.js';31import { ChatterboxTtsProvider } from './chatterbox.js';
31import { KokoroTtsProvider } from './kokoro.js';32import { KokoroTtsProvider } from './kokoro.js';
32import { TtsWebuiProvider } from './tts-webui.js';33import { TtsWebuiProvider } from './tts-webui.js';
@@ -96,6 +97,7 @@ const ttsProviders = {
96 Edge: EdgeTtsProvider,97 Edge: EdgeTtsProvider,
97 ElevenLabs: ElevenLabsTtsProvider,98 ElevenLabs: ElevenLabsTtsProvider,
98 'Google Translate': GoogleTranslateTtsProvider,99 'Google Translate': GoogleTranslateTtsProvider,
100 'Google Gemini TTS': GoogleNativeTtsProvider,
99 GSVI: GSVITtsProvider,101 GSVI: GSVITtsProvider,
100 'GPT-SoVITS-V2 (Unofficial)': GptSovitsV2Provider,102 'GPT-SoVITS-V2 (Unofficial)': GptSovitsV2Provider,
101 Kokoro: KokoroTtsProvider,103 Kokoro: KokoroTtsProvider,
src/endpoints/google.js+212 -60
@@ -6,11 +6,34 @@ import crypto from 'node:crypto';
66
7import { readSecret, SECRET_KEYS } from './secrets.js';7import { readSecret, SECRET_KEYS } from './secrets.js';
8import { GEMINI_SAFETY } from '../constants.js';8import { GEMINI_SAFETY } from '../constants.js';
9import { trimTrailingSlash } from '../util.js';9import { getConfigValue, trimTrailingSlash } from '../util.js';
1010
11const API_MAKERSUITE = 'https://generativelanguage.googleapis.com';11const API_MAKERSUITE = 'https://generativelanguage.googleapis.com';
12const API_VERTEX_AI = 'https://us-central1-aiplatform.googleapis.com';12const API_VERTEX_AI = 'https://us-central1-aiplatform.googleapis.com';
1313
14function createWavHeader(dataSize, sampleRate, numChannels = 1, bitsPerSample = 16) {
15 const header = Buffer.alloc(44);
16 header.write('RIFF', 0);
17 header.writeUInt32LE(36 + dataSize, 4);
18 header.write('WAVE', 8);
19 header.write('fmt ', 12);
20 header.writeUInt32LE(16, 16);
21 header.writeUInt16LE(1, 20);
22 header.writeUInt16LE(numChannels, 22);
23 header.writeUInt32LE(sampleRate, 24);
24 header.writeUInt32LE(sampleRate * numChannels * bitsPerSample / 8, 28);
25 header.writeUInt16LE(numChannels * bitsPerSample / 8, 32);
26 header.writeUInt16LE(bitsPerSample, 34);
27 header.write('data', 36);
28 header.writeUInt32LE(dataSize, 40);
29 return header;
30}
31
32function createCompleteWavFile(pcmData, sampleRate) {
33 const header = createWavHeader(pcmData.length, sampleRate);
34 return Buffer.concat([header, pcmData]);
35}
36
14// Vertex AI authentication helper functions37// Vertex AI authentication helper functions
15export async function getVertexAIAuth(request) {38export async function getVertexAIAuth(request) {
16 const authMode = request.body.vertexai_auth_mode || 'express';39 const authMode = request.body.vertexai_auth_mode || 'express';
@@ -128,73 +151,83 @@ export function getProjectIdFromServiceAccount(serviceAccount) {
128 return projectId;151 return projectId;
129}152}
130153
154/**
155 * Generates Google API URL and headers based on request configuration
156 * @param {express.Request} request Express request object
157 * @param {string} model Model name to use
158 * @param {string} endpoint API endpoint (default: 'generateContent')
159 * @returns {Promise<{url: string, headers: object, apiName: string}>} URL, headers, and API name
160 */
161export async function getGoogleApiConfig(request, model, endpoint = 'generateContent') {
162 const useVertexAi = request.body.api === 'vertexai';
163 const region = request.body.vertexai_region || 'us-central1';
164 const apiName = useVertexAi ? 'Google Vertex AI' : 'Google AI Studio';
165
166 let url;
167 let headers = {
168 'Content-Type': 'application/json',
169 };
170
171 if (useVertexAi) {
172 // Get authentication for Vertex AI
173 const { authHeader, authType } = await getVertexAIAuth(request);
174
175 if (authType === 'express') {
176 // Express mode: use API key parameter
177 const keyParam = authHeader.replace('Bearer ', '');
178 const projectId = request.body.vertexai_express_project_id;
179 const baseUrl = region === 'global'
180 ? 'https://aiplatform.googleapis.com'
181 : `https://${region}-aiplatform.googleapis.com`;
182 url = projectId
183 ? `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}?key=${keyParam}`
184 : `${baseUrl}/v1/publishers/google/models/${model}:${endpoint}?key=${keyParam}`;
185 } else if (authType === 'full') {
186 // Full mode: use project-specific URL with Authorization header
187 // Get project ID from Service Account JSON
188 const serviceAccountJson = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT);
189 if (!serviceAccountJson) {
190 throw new Error('Vertex AI Service Account JSON is missing.');
191 }
192
193 let projectId;
194 try {
195 const serviceAccount = JSON.parse(serviceAccountJson);
196 projectId = getProjectIdFromServiceAccount(serviceAccount);
197 } catch (error) {
198 throw new Error('Failed to extract project ID from Service Account JSON.');
199 }
200 // Handle global region differently - no region prefix in hostname
201 url = region === 'global'
202 ? `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}`
203 : `https://${region}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}`;
204 headers['Authorization'] = authHeader;
205 } else {
206 // Proxy mode: use Authorization header
207 const apiUrl = trimTrailingSlash(request.body.reverse_proxy || API_VERTEX_AI);
208 url = `${apiUrl}/v1/publishers/google/models/${model}:${endpoint}`;
209 headers['Authorization'] = authHeader;
210 }
211 } else {
212 // Google AI Studio
213 const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE);
214 const apiUrl = trimTrailingSlash(request.body.reverse_proxy || API_MAKERSUITE);
215 const apiVersion = getConfigValue('gemini.apiVersion', 'v1beta');
216 url = `${apiUrl}/${apiVersion}/models/${model}:${endpoint}?key=${apiKey}`;
217 }
218
219 return { url, headers, apiName };
220}
221
131export const router = express.Router();222export const router = express.Router();
132223
133router.post('/caption-image', async (request, response) => {224router.post('/caption-image', async (request, response) => {
134 try {225 try {
135 const mimeType = request.body.image.split(';')[0].split(':')[1];226 const mimeType = request.body.image.split(';')[0].split(':')[1];
136 const base64Data = request.body.image.split(',')[1];227 const base64Data = request.body.image.split(',')[1];
137 const useVertexAi = request.body.api === 'vertexai';
138 const apiName = useVertexAi ? 'Google Vertex AI' : 'Google AI Studio';
139 const model = request.body.model || 'gemini-2.0-flash';228 const model = request.body.model || 'gemini-2.0-flash';
229 const { url, headers, apiName } = await getGoogleApiConfig(request, model);
140230
141 let url;
142 let headers = {
143 'Content-Type': 'application/json',
144 };
145
146 if (useVertexAi) {
147 // Get authentication for Vertex AI
148 const { authHeader, authType } = await getVertexAIAuth(request);
149
150 if (authType === 'express') {
151 // Express mode: use API key parameter
152 const keyParam = authHeader.replace('Bearer ', '');
153 const region = request.body.vertexai_region || 'us-central1';
154 const projectId = request.body.vertexai_express_project_id;
155 const baseUrl = region === 'global'
156 ? 'https://aiplatform.googleapis.com'
157 : `https://${region}-aiplatform.googleapis.com`;
158 url = projectId
159 ? `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:generateContent?key=${keyParam}`
160 : `${baseUrl}/v1/publishers/google/models/${model}:generateContent?key=${keyParam}`;
161 } else if (authType === 'full') {
162 // Full mode: use project-specific URL with Authorization header
163 // Get project ID from Service Account JSON
164 const serviceAccountJson = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT);
165 if (!serviceAccountJson) {
166 console.warn('Vertex AI Service Account JSON is missing.');
167 return response.status(400).send({ error: true });
168 }
169
170 let projectId;
171 try {
172 const serviceAccount = JSON.parse(serviceAccountJson);
173 projectId = getProjectIdFromServiceAccount(serviceAccount);
174 } catch (error) {
175 console.error('Failed to extract project ID from Service Account JSON:', error);
176 return response.status(400).send({ error: true });
177 }
178 const region = request.body.vertexai_region || 'us-central1';
179 // Handle global region differently - no region prefix in hostname
180 if (region === 'global') {
181 url = `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:generateContent`;
182 } else {
183 url = `https://${region}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:generateContent`;
184 }
185 headers['Authorization'] = authHeader;
186 } else {
187 // Proxy mode: use Authorization header
188 const apiUrl = trimTrailingSlash(request.body.reverse_proxy || API_VERTEX_AI);
189 url = `${apiUrl}/v1/publishers/google/models/${model}:generateContent`;
190 headers['Authorization'] = authHeader;
191 }
192 } else {
193 // Google AI Studio
194 const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE);
195 const apiUrl = trimTrailingSlash(request.body.reverse_proxy || API_MAKERSUITE);
196 url = `${apiUrl}/v1beta/models/${model}:generateContent?key=${apiKey}`;
197 }
198 const body = {231 const body = {
199 contents: [{232 contents: [{
200 role: 'user',233 role: 'user',
@@ -266,3 +299,122 @@ router.post('/generate-voice', async (request, response) => {
266 response.status(500).send('Internal server error');299 response.status(500).send('Internal server error');
267 }300 }
268});301});
302
303router.post('/list-native-voices', async (_, response) => {
304 try {
305 // Hardcoded Gemini native TTS voices from official documentation
306 // Source: https://ai.google.dev/gemini-api/docs/speech-generation#voices
307 const voices = [
308 { name: 'Zephyr', voice_id: 'Zephyr', lang: 'en-US', description: 'Bright' },
309 { name: 'Puck', voice_id: 'Puck', lang: 'en-US', description: 'Upbeat' },
310 { name: 'Charon', voice_id: 'Charon', lang: 'en-US', description: 'Informative' },
311 { name: 'Kore', voice_id: 'Kore', lang: 'en-US', description: 'Firm' },
312 { name: 'Fenrir', voice_id: 'Fenrir', lang: 'en-US', description: 'Excitable' },
313 { name: 'Leda', voice_id: 'Leda', lang: 'en-US', description: 'Youthful' },
314 { name: 'Orus', voice_id: 'Orus', lang: 'en-US', description: 'Firm' },
315 { name: 'Aoede', voice_id: 'Aoede', lang: 'en-US', description: 'Breezy' },
316 { name: 'Callirhoe', voice_id: 'Callirhoe', lang: 'en-US', description: 'Easy-going' },
317 { name: 'Autonoe', voice_id: 'Autonoe', lang: 'en-US', description: 'Bright' },
318 { name: 'Enceladus', voice_id: 'Enceladus', lang: 'en-US', description: 'Breathy' },
319 { name: 'Iapetus', voice_id: 'Iapetus', lang: 'en-US', description: 'Clear' },
320 { name: 'Umbriel', voice_id: 'Umbriel', lang: 'en-US', description: 'Easy-going' },
321 { name: 'Algieba', voice_id: 'Algieba', lang: 'en-US', description: 'Smooth' },
322 { name: 'Despina', voice_id: 'Despina', lang: 'en-US', description: 'Smooth' },
323 { name: 'Erinome', voice_id: 'Erinome', lang: 'en-US', description: 'Clear' },
324 { name: 'Algenib', voice_id: 'Algenib', lang: 'en-US', description: 'Gravelly' },
325 { name: 'Rasalgethi', voice_id: 'Rasalgethi', lang: 'en-US', description: 'Informative' },
326 { name: 'Laomedeia', voice_id: 'Laomedeia', lang: 'en-US', description: 'Upbeat' },
327 { name: 'Achernar', voice_id: 'Achernar', lang: 'en-US', description: 'Soft' },
328 { name: 'Alnilam', voice_id: 'Alnilam', lang: 'en-US', description: 'Firm' },
329 { name: 'Schedar', voice_id: 'Schedar', lang: 'en-US', description: 'Even' },
330 { name: 'Gacrux', voice_id: 'Gacrux', lang: 'en-US', description: 'Mature' },
331 { name: 'Pulcherrima', voice_id: 'Pulcherrima', lang: 'en-US', description: 'Forward' },
332 { name: 'Achird', voice_id: 'Achird', lang: 'en-US', description: 'Friendly' },
333 { name: 'Zubenelgenubi', voice_id: 'Zubenelgenubi', lang: 'en-US', description: 'Casual' },
334 { name: 'Vindemiatrix', voice_id: 'Vindemiatrix', lang: 'en-US', description: 'Gentle' },
335 { name: 'Sadachbia', voice_id: 'Sadachbia', lang: 'en-US', description: 'Lively' },
336 { name: 'Sadaltager', voice_id: 'Sadaltager', lang: 'en-US', description: 'Knowledgeable' },
337 { name: 'Sulafat', voice_id: 'Sulafat', lang: 'en-US', description: 'Warm' },
338 ];
339 return response.json({ voices });
340 } catch (error) {
341 console.error('Failed to return Google TTS voices:', error);
342 response.sendStatus(500);
343 }
344});
345
346router.post('/generate-native-tts', async (request, response) => {
347 try {
348 const { text, voice, model } = request.body;
349 const { url, headers, apiName } = await getGoogleApiConfig(request, model);
350
351 console.debug(`${apiName} TTS request`, { model, text, voice });
352
353 const requestBody = {
354 contents: [{
355 role: 'user',
356 parts: [{ text: text }],
357 }],
358 generationConfig: {
359 responseModalities: ['AUDIO'],
360 speechConfig: {
361 voiceConfig: {
362 prebuiltVoiceConfig: {
363 voiceName: voice,
364 },
365 },
366 },
367 },
368 safetySettings: GEMINI_SAFETY,
369 };
370
371 const result = await fetch(url, {
372 method: 'POST',
373 headers: headers,
374 body: JSON.stringify(requestBody),
375 });
376
377 if (!result.ok) {
378 const errorText = await result.text();
379 console.error(`${apiName} TTS API error: ${result.status} ${result.statusText}`, errorText);
380 const errorMessage = JSON.parse(errorText).error?.message || 'TTS generation failed.';
381 return response.status(result.status).json({ error: errorMessage });
382 }
383
384 /** @type {any} */
385 const data = await result.json();
386 const audioPart = data?.candidates?.[0]?.content?.parts?.[0];
387 const audioData = audioPart?.inlineData?.data;
388 const mimeType = audioPart?.inlineData?.mimeType;
389
390 if (!audioData) {
391 return response.status(500).json({ error: 'No audio data found in response' });
392 }
393
394 const audioBuffer = Buffer.from(audioData, 'base64');
395
396 //If the audio is raw PCM, wrap it in a WAV header and send it.
397 if (mimeType && mimeType.toLowerCase().includes('audio/l16')) {
398 const rateMatch = mimeType.match(/rate=(\d+)/);
399 const sampleRate = rateMatch ? parseInt(rateMatch[1], 10) : 24000;
400 const pcmData = audioBuffer;
401
402 // Create a complete, playable WAV file buffer.
403 const wavBuffer = createCompleteWavFile(pcmData, sampleRate);
404
405 // Send the WAV file directly to the browser. This is much faster.
406 response.setHeader('Content-Type', 'audio/wav');
407 return response.send(wavBuffer);
408 }
409
410 // Fallback for any other audio format Google might send in the future.
411 response.setHeader('Content-Type', mimeType || 'application/octet-stream');
412 response.send(audioBuffer);
413 } catch (error) {
414 console.error('Google TTS generation failed:', error);
415 if (!response.headersSent) {
416 return response.status(500).json({ error: 'Internal server error during TTS generation' });
417 }
418 return response.end();
419 }
420});