Pollinations: add as TTS provider

4607132576dce358e544faef5bcd683f16b3211a

Cohee <18619528+Cohee1207@users.noreply.github.com>

3 files changed, +214 -0Ignore whitespace
public/scripts/extensions/tts/index.js+2 -0
@@ -31,6 +31,7 @@ import { GoogleNativeTtsProvider } from './google-native.js';
31import { ChatterboxTtsProvider } from './chatterbox.js';31import { ChatterboxTtsProvider } from './chatterbox.js';
32import { KokoroTtsProvider } from './kokoro.js';32import { KokoroTtsProvider } from './kokoro.js';
33import { TtsWebuiProvider } from './tts-webui.js';33import { TtsWebuiProvider } from './tts-webui.js';
34import { PollinationsTtsProvider } from './pollinations.js';
3435
35const UPDATE_INTERVAL = 1000;36const UPDATE_INTERVAL = 1000;
36const wrapper = new ModuleWorkerWrapper(moduleWorker);37const wrapper = new ModuleWorkerWrapper(moduleWorker);
@@ -129,6 +130,7 @@ const ttsProviders = {
129 Novel: NovelTtsProvider,130 Novel: NovelTtsProvider,
130 OpenAI: OpenAITtsProvider,131 OpenAI: OpenAITtsProvider,
131 'OpenAI Compatible': OpenAICompatibleTtsProvider,132 'OpenAI Compatible': OpenAICompatibleTtsProvider,
133 Pollinations: PollinationsTtsProvider,
132 SBVits2: SBVits2TtsProvider,134 SBVits2: SBVits2TtsProvider,
133 Silero: SileroTtsProvider,135 Silero: SileroTtsProvider,
134 SpeechT5: SpeechT5TtsProvider,136 SpeechT5: SpeechT5TtsProvider,
public/scripts/extensions/tts/pollinations.js+151 -0
@@ -0,0 +1,151 @@
1import { getRequestHeaders } from '../../../script.js';
2import { splitRecursive } from '../../utils.js';
3import { getPreviewString, saveTtsProviderSettings } from './index.js';
4
5export class PollinationsTtsProvider {
6 settings;
7 voices = [];
8 separator = ' . ';
9 audioElement = document.createElement('audio');
10
11 defaultSettings = {
12 // TODO: Make this configurable
13 model: 'openai-audio',
14 voiceMap: {},
15 };
16
17 get settingsHtml() {
18 return '';
19 }
20
21 onSettingsChange() {
22 this.voices = [];
23 saveTtsProviderSettings();
24 }
25
26 async loadSettings(settings) {
27 // Populate Provider UI given input settings
28 if (Object.keys(settings).length == 0) {
29 console.info('Using default TTS Provider settings');
30 }
31
32 // Only accept keys defined in defaultSettings
33 this.settings = this.defaultSettings;
34
35 for (const key in settings) {
36 if (key in this.settings) {
37 this.settings[key] = settings[key];
38 } else {
39 throw `Invalid setting passed to TTS Provider: ${key}`;
40 }
41 }
42
43 try {
44 await this.checkReady();
45 console.debug('Pollinations TTS: Settings loaded');
46 } catch {
47 console.debug('Pollinations TTS: Settings loaded, but not ready');
48 }
49 }
50
51 // Perform a simple readiness check by trying to fetch voiceIds
52 async checkReady() {
53 await this.fetchTtsVoiceObjects();
54 }
55
56 async onRefreshClick() {
57 await this.checkReady();
58 }
59
60 //#################//
61 // TTS Interfaces //
62 //#################//
63
64 async getVoice(voiceName) {
65 if (this.voices.length == 0) {
66 this.voices = await this.fetchTtsVoiceObjects();
67 }
68 const match = this.voices.filter(
69 voice => voice.name == voiceName || voice.voice_id == voiceName,
70 )[0];
71 if (!match) {
72 throw `TTS Voice name ${voiceName} not found`;
73 }
74 return match;
75 }
76
77 /**
78 * Generate TTS audio for the given text using the specified voice.
79 * @param {string} text Text to generate
80 * @param {string} voiceId Voice ID
81 * @returns {AsyncGenerator<Response>} Audio response generator
82 */
83 generateTts(text, voiceId) {
84 return this.fetchTtsGeneration(text, voiceId);
85 }
86
87 //###########//
88 // API CALLS //
89 //###########//
90 async fetchTtsVoiceObjects() {
91 const response = await fetch('/api/speech/pollinations/voices', {
92 method: 'POST',
93 headers: getRequestHeaders(),
94 body: JSON.stringify({ model: this.settings.model }),
95 });
96
97 if (!response.ok) {
98 throw new Error(`HTTP ${response.status}: ${await response.text()}`);
99 }
100 const responseJson = await response.json();
101 return responseJson
102 .sort()
103 .map(x => ({ name: x, voice_id: x, preview_url: false, lang: 'en-US' }));
104 }
105
106 /**
107 * Preview TTS for a given voice ID.
108 * @param {string} id Voice ID
109 */
110 async previewTtsVoice(id) {
111 this.audioElement.pause();
112 this.audioElement.currentTime = 0;
113 const voice = await this.getVoice(id);
114 const text = getPreviewString(voice.lang);
115 for await (const response of this.generateTts(text, id)) {
116 const audio = await response.blob();
117 const url = URL.createObjectURL(audio);
118 await new Promise(resolve => {
119 const audioElement = new Audio();
120 audioElement.src = url;
121 audioElement.play();
122 audioElement.onended = () => resolve();
123 });
124 URL.revokeObjectURL(url);
125 }
126 }
127
128 async* fetchTtsGeneration(text, voiceId) {
129 const MAX_LENGTH = 1000;
130 console.info(`Generating new TTS for voice_id ${voiceId}`);
131 const chunks = splitRecursive(text, MAX_LENGTH);
132 for (const chunk of chunks) {
133 const response = await fetch('/api/speech/pollinations/generate', {
134 method: 'POST',
135 headers: getRequestHeaders(),
136 body: JSON.stringify({
137 model: this.settings.model,
138 text: 'Say exactly this and nothing else:' + '\n' + chunk,
139 voice: voiceId,
140 }),
141 });
142
143 if (!response.ok) {
144 toastr.error(response.statusText, 'TTS Generation Failed');
145 throw new Error(`HTTP ${response.status}: ${await response.text()}`);
146 }
147
148 yield response;
149 }
150 }
151}
src/endpoints/speech.js+61 -0
@@ -1,7 +1,9 @@
1import { Buffer } from 'node:buffer';1import { Buffer } from 'node:buffer';
2import express from 'express';2import express from 'express';
3import wavefile from 'wavefile';3import wavefile from 'wavefile';
4import fetch from 'node-fetch';
4import { getPipeline } from '../transformers.js';5import { getPipeline } from '../transformers.js';
6import { forwardFetchResponse } from '../util.js';
57
6export const router = express.Router();8export const router = express.Router();
79
@@ -76,3 +78,62 @@ router.post('/synthesize', async (req, res) => {
76 return res.sendStatus(500);78 return res.sendStatus(500);
77 }79 }
78});80});
81
82const pollinations = express.Router();
83
84pollinations.post('/voices', async (req, res) => {
85 try {
86 const model = req.body.model || 'openai-audio';
87
88 const response = await fetch('https://text.pollinations.ai/models');
89
90 if (!response.ok) {
91 throw new Error('Failed to fetch Pollinations models');
92 }
93
94 const data = await response.json();
95
96 if (!Array.isArray(data)) {
97 throw new Error('Invalid data format received from Pollinations');
98 }
99
100 const audioModelData = data.find(m => m.name === model);
101 if (!audioModelData || !Array.isArray(audioModelData.voices)) {
102 throw new Error('No voices found for the specified model');
103 }
104
105 const voices = audioModelData.voices;
106 return res.json(voices);
107 } catch (error) {
108 console.error(error);
109 return res.sendStatus(500);
110 }
111});
112
113pollinations.post('/generate', async (req, res) => {
114 try {
115 const text = req.body.text;
116 const model = req.body.model || 'openai-audio';
117 const voice = req.body.voice || 'alloy';
118
119 const url = new URL(`https://text.pollinations.ai/generate/${encodeURIComponent(text)}`);
120 url.searchParams.append('model', model);
121 url.searchParams.append('voice', voice);
122 url.searchParams.append('referrer', 'sillytavern');
123
124 const response = await fetch(url);
125
126 if (!response.ok) {
127 const text = await response.text();
128 throw new Error(`Failed to generate audio from Pollinations: ${text}`);
129 }
130
131 res.set('Content-Type', 'audio/mpeg');
132 forwardFetchResponse(response, res);
133 } catch (error) {
134 console.error(error);
135 return res.sendStatus(500);
136 }
137});
138
139router.use('/pollinations', pollinations);