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';
3131import { ChatterboxTtsProvider } from './chatterbox.js';
3232import { KokoroTtsProvider } from './kokoro.js';
3333import { TtsWebuiProvider } from './tts-webui.js';
34+import { PollinationsTtsProvider } from './pollinations.js';
3435
3536const UPDATE_INTERVAL = 1000;
3637const wrapper = new ModuleWorkerWrapper(moduleWorker);
@@ -129,6 +130,7 @@ const ttsProviders = {
129130 Novel: NovelTtsProvider,
130131 OpenAI: OpenAITtsProvider,
131132 'OpenAI Compatible': OpenAICompatibleTtsProvider,
133+ Pollinations: PollinationsTtsProvider,
132134 SBVits2: SBVits2TtsProvider,
133135 Silero: SileroTtsProvider,
134136 SpeechT5: SpeechT5TtsProvider,
public/scripts/extensions/tts/pollinations.js+151 -0
@@ -0,0 +1,151 @@
1+import { getRequestHeaders } from '../../../script.js';
2+import { splitRecursive } from '../../utils.js';
3+import { getPreviewString, saveTtsProviderSettings } from './index.js';
4+
5+export 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 @@
11import { Buffer } from 'node:buffer';
22import express from 'express';
33import wavefile from 'wavefile';
4+import fetch from 'node-fetch';
45import { getPipeline } from '../transformers.js';
6+import { forwardFetchResponse } from '../util.js';
57
68export const router = express.Router();
79
@@ -76,3 +78,62 @@ router.post('/synthesize', async (req, res) => {
7678 return res.sendStatus(500);
7779 }
7880});
81+
82+const pollinations = express.Router();
83+
84+pollinations.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+
113+pollinations.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+
139+router.use('/pollinations', pollinations);