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 | import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; | 27 | import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 28 | import { POPUP_TYPE, callGenericPopup } from '../../popup.js'; | 28 | import { POPUP_TYPE, callGenericPopup } from '../../popup.js'; |
| 29 | import { GoogleTranslateTtsProvider } from './google-translate.js'; | 29 | import { GoogleTranslateTtsProvider } from './google-translate.js'; |
| 30 | import { GoogleNativeTtsProvider } from './google-native.js'; | ||
| 30 | import { ChatterboxTtsProvider } from './chatterbox.js'; | 31 | import { ChatterboxTtsProvider } from './chatterbox.js'; |
| 31 | import { KokoroTtsProvider } from './kokoro.js'; | 32 | import { KokoroTtsProvider } from './kokoro.js'; |
| 32 | import { TtsWebuiProvider } from './tts-webui.js'; | 33 | import { 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, |
| @@ -6,11 +6,34 @@ import crypto from 'node:crypto'; | |||
| 6 | 6 | ||
| 7 | import { readSecret, SECRET_KEYS } from './secrets.js'; | 7 | import { readSecret, SECRET_KEYS } from './secrets.js'; |
| 8 | import { GEMINI_SAFETY } from '../constants.js'; | 8 | import { GEMINI_SAFETY } from '../constants.js'; |
| 9 | import { trimTrailingSlash } from '../util.js'; | 9 | import { getConfigValue, trimTrailingSlash } from '../util.js'; |
| 10 | 10 | ||
| 11 | const API_MAKERSUITE = 'https://generativelanguage.googleapis.com'; | 11 | const API_MAKERSUITE = 'https://generativelanguage.googleapis.com'; |
| 12 | const API_VERTEX_AI = 'https://us-central1-aiplatform.googleapis.com'; | 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 | // Vertex AI authentication helper functions | 37 | // Vertex AI authentication helper functions |
| 15 | export async function getVertexAIAuth(request) { | 38 | export async function getVertexAIAuth(request) { |
| 16 | const authMode = request.body.vertexai_auth_mode || 'express'; | 39 | const authMode = request.body.vertexai_auth_mode || 'express'; |
| @@ -128,15 +151,17 @@ export function getProjectIdFromServiceAccount(serviceAccount) { | |||
| 128 | return projectId; | 151 | return projectId; |
| 129 | } | 152 | } |
| 130 | 153 | ||
| 131 | export const router = express.Router(); | 154 | /** |
| 132 | 155 | * Generates Google API URL and headers based on request configuration | |
| 133 | router.post('/caption-image', async (request, response) => { | 156 | * @param {express.Request} request Express request object |
| 134 | try { | 157 | * @param {string} model Model name to use |
| 135 | const mimeType = request.body.image.split(';')[0].split(':')[1]; | 158 | * @param {string} endpoint API endpoint (default: 'generateContent') |
| 136 | const base64Data = request.body.image.split(',')[1]; | 159 | * @returns {Promise<{url: string, headers: object, apiName: string}>} URL, headers, and API name |
| 160 | */ | ||
| 161 | export async function getGoogleApiConfig(request, model, endpoint = 'generateContent') { | ||
| 137 | const useVertexAi = request.body.api === 'vertexai'; | 162 | const useVertexAi = request.body.api === 'vertexai'; |
| 163 | const region = request.body.vertexai_region || 'us-central1'; | ||
| 138 | const apiName = useVertexAi ? 'Google Vertex AI' : 'Google AI Studio'; | 164 | const apiName = useVertexAi ? 'Google Vertex AI' : 'Google AI Studio'; |
| 139 | const model = request.body.model || 'gemini-2.0-flash'; | ||
| 140 | 165 | ||
| 141 | let url; | 166 | let url; |
| 142 | let headers = { | 167 | let headers = { |
| @@ -150,21 +175,19 @@ router.post('/caption-image', async (request, response) => { | |||
| 150 | if (authType === 'express') { | 175 | if (authType === 'express') { |
| 151 | // Express mode: use API key parameter | 176 | // Express mode: use API key parameter |
| 152 | const keyParam = authHeader.replace('Bearer ', ''); | 177 | const keyParam = authHeader.replace('Bearer ', ''); |
| 153 | const region = request.body.vertexai_region || 'us-central1'; | ||
| 154 | const projectId = request.body.vertexai_express_project_id; | 178 | const projectId = request.body.vertexai_express_project_id; |
| 155 | const baseUrl = region === 'global' | 179 | const baseUrl = region === 'global' |
| 156 | ? 'https://aiplatform.googleapis.com' | 180 | ? 'https://aiplatform.googleapis.com' |
| 157 | : `https://${region}-aiplatform.googleapis.com`; | 181 | : `https://${region}-aiplatform.googleapis.com`; |
| 158 | url = projectId | 182 | url = projectId |
| 159 | ? `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:generateContent?key=${keyParam}` | 183 | ? `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}?key=${keyParam}` |
| 160 | : `${baseUrl}/v1/publishers/google/models/${model}:generateContent?key=${keyParam}`; | 184 | : `${baseUrl}/v1/publishers/google/models/${model}:${endpoint}?key=${keyParam}`; |
| 161 | } else if (authType === 'full') { | 185 | } else if (authType === 'full') { |
| 162 | // Full mode: use project-specific URL with Authorization header | 186 | // Full mode: use project-specific URL with Authorization header |
| 163 | // Get project ID from Service Account JSON | 187 | // Get project ID from Service Account JSON |
| 164 | const serviceAccountJson = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT); | 188 | const serviceAccountJson = readSecret(request.user.directories, SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT); |
| 165 | if (!serviceAccountJson) { | 189 | if (!serviceAccountJson) { |
| 166 | console.warn('Vertex AI Service Account JSON is missing.'); | 190 | throw new Error('Vertex AI Service Account JSON is missing.'); |
| 167 | return response.status(400).send({ error: true }); | ||
| 168 | } | 191 | } |
| 169 | 192 | ||
| 170 | let projectId; | 193 | let projectId; |
| @@ -172,29 +195,39 @@ router.post('/caption-image', async (request, response) => { | |||
| 172 | const serviceAccount = JSON.parse(serviceAccountJson); | 195 | const serviceAccount = JSON.parse(serviceAccountJson); |
| 173 | projectId = getProjectIdFromServiceAccount(serviceAccount); | 196 | projectId = getProjectIdFromServiceAccount(serviceAccount); |
| 174 | } catch (error) { | 197 | } catch (error) { |
| 175 | console.error('Failed to extract project ID from Service Account JSON:', error); | 198 | throw new Error('Failed to extract project ID from Service Account JSON.'); |
| 176 | return response.status(400).send({ error: true }); | ||
| 177 | } | 199 | } |
| 178 | const region = request.body.vertexai_region || 'us-central1'; | ||
| 179 | // Handle global region differently - no region prefix in hostname | 200 | // Handle global region differently - no region prefix in hostname |
| 180 | if (region === 'global') { | 201 | url = region === 'global' |
| 181 | url = `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:generateContent`; | 202 | ? `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}` |
| 182 | } else { | 203 | : `https://${region}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:${endpoint}`; |
| 183 | url = `https://${region}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${model}:generateContent`; | ||
| 184 | } | ||
| 185 | headers['Authorization'] = authHeader; | 204 | headers['Authorization'] = authHeader; |
| 186 | } else { | 205 | } else { |
| 187 | // Proxy mode: use Authorization header | 206 | // Proxy mode: use Authorization header |
| 188 | const apiUrl = trimTrailingSlash(request.body.reverse_proxy || API_VERTEX_AI); | 207 | const apiUrl = trimTrailingSlash(request.body.reverse_proxy || API_VERTEX_AI); |
| 189 | url = `${apiUrl}/v1/publishers/google/models/${model}:generateContent`; | 208 | url = `${apiUrl}/v1/publishers/google/models/${model}:${endpoint}`; |
| 190 | headers['Authorization'] = authHeader; | 209 | headers['Authorization'] = authHeader; |
| 191 | } | 210 | } |
| 192 | } else { | 211 | } else { |
| 193 | // Google AI Studio | 212 | // Google AI Studio |
| 194 | const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.MAKERSUITE); | 213 | 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); | 214 | const apiUrl = trimTrailingSlash(request.body.reverse_proxy || API_MAKERSUITE); |
| 196 | url = `${apiUrl}/v1beta/models/${model}:generateContent?key=${apiKey}`; | 215 | const apiVersion = getConfigValue('gemini.apiVersion', 'v1beta'); |
| 216 | url = `${apiUrl}/${apiVersion}/models/${model}:${endpoint}?key=${apiKey}`; | ||
| 197 | } | 217 | } |
| 218 | |||
| 219 | return { url, headers, apiName }; | ||
| 220 | } | ||
| 221 | |||
| 222 | export const router = express.Router(); | ||
| 223 | |||
| 224 | router.post('/caption-image', async (request, response) => { | ||
| 225 | try { | ||
| 226 | const mimeType = request.body.image.split(';')[0].split(':')[1]; | ||
| 227 | const base64Data = request.body.image.split(',')[1]; | ||
| 228 | const model = request.body.model || 'gemini-2.0-flash'; | ||
| 229 | const { url, headers, apiName } = await getGoogleApiConfig(request, model); | ||
| 230 | |||
| 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 | |||
| 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 | }); | ||