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>
Signed| @@ -0,0 +1,195 @@ | ||
| 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">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 | +} | |
| @@ -27,6 +27,7 @@ import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashComm | ||
| 27 | 27 | import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 28 | 28 | import { POPUP_TYPE, callGenericPopup } from '../../popup.js'; |
| 29 | 29 | import { GoogleTranslateTtsProvider } from './google-translate.js'; |
| 30 | +import { GoogleNativeTtsProvider } from './google-native.js'; | |
| 30 | 31 | import { ChatterboxTtsProvider } from './chatterbox.js'; |
| 31 | 32 | import { KokoroTtsProvider } from './kokoro.js'; |
| 32 | 33 | import { TtsWebuiProvider } from './tts-webui.js'; |
| @@ -96,6 +97,7 @@ const ttsProviders = { | ||
| 96 | 97 | Edge: EdgeTtsProvider, |
| 97 | 98 | ElevenLabs: ElevenLabsTtsProvider, |
| 98 | 99 | 'Google Translate': GoogleTranslateTtsProvider, |
| 100 | + 'Google Gemini TTS': GoogleNativeTtsProvider, | |
| 99 | 101 | GSVI: GSVITtsProvider, |
| 100 | 102 | 'GPT-SoVITS-V2 (Unofficial)': GptSovitsV2Provider, |
| 101 | 103 | Kokoro: KokoroTtsProvider, |
| @@ -6,11 +6,34 @@ import crypto from 'node:crypto'; | ||
| 6 | 6 | |
| 7 | 7 | import { readSecret, SECRET_KEYS } from './secrets.js'; |
| 8 | 8 | import { GEMINI_SAFETY } from '../constants.js'; |
| 9 | 9 | import { getConfigValue, trimTrailingSlash } from '../util.js'; |
| 10 | 10 | |
| 11 | 11 | const API_MAKERSUITE = 'https://generativelanguage.googleapis.com'; |
| 12 | 12 | const API_VERTEX_AI = 'https://us-central1-aiplatform.googleapis.com'; |
| 13 | 13 | |
| 14 | +function 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 | + | |
| 32 | +function createCompleteWavFile(pcmData, sampleRate) { | |
| 33 | + const header = createWavHeader(pcmData.length, sampleRate); | |
| 34 | + return Buffer.concat([header, pcmData]); | |
| 35 | +} | |
| 36 | + | |
| 14 | 37 | // Vertex AI authentication helper functions |
| 15 | 38 | export async function getVertexAIAuth(request) { |
| 16 | 39 | const authMode = request.body.vertexai_auth_mode || 'express'; |
| @@ -128,73 +151,83 @@ export function getProjectIdFromServiceAccount(serviceAccount) { | ||
| 128 | 151 | return projectId; |
| 129 | 152 | } |
| 130 | 153 | |
| 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 | + */ | |
| 161 | +export 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 | + | |
| 131 | 222 | export const router = express.Router(); |
| 132 | 223 | |
| 133 | 224 | router.post('/caption-image', async (request, response) => { |
| 134 | 225 | try { |
| 135 | 226 | const mimeType = request.body.image.split(';')[0].split(':')[1]; |
| 136 | 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 | 228 | const model = request.body.model || 'gemini-2.0-flash'; |
| 229 | + const { url, headers, apiName } = await getGoogleApiConfig(request, model); | |
| 140 | 230 | |
| 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 | 231 | const body = { |
| 199 | 232 | contents: [{ |
| 200 | 233 | role: 'user', |
| @@ -266,3 +299,122 @@ router.post('/generate-voice', async (request, response) => { | ||
| 266 | 299 | response.status(500).send('Internal server error'); |
| 267 | 300 | } |
| 268 | 301 | }); |
| 302 | + | |
| 303 | +router.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 | + | |
| 346 | +router.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 | +}); | |