TTS WebUI provider (#4097) * initial version * volume and additional parameters for chatterbox * add voice fetch * deduplicate code * fix eslint * add all parameters * use own secret * remove (Unofficial) tag * use only client side requests, fix voice discovery * enable streaming by default * change openai_compatible to tts_webui in inputs * remove unused volume helpers * extract PCM processor * eslint fix * remove unused secrets * Remove obsolete secret IDs --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

0fc82432ac5025d96c0db82b86cbb1e3df10cc65

Roberts Slisans <roberts.slisans@gmail.com>

Signed
3 files changed, +645 -0Ignore whitespace
public/scripts/extensions/tts/index.js+2 -0
@@ -28,6 +28,7 @@ import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.
2828import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
2929import { GoogleTranslateTtsProvider } from './google-translate.js';
3030import { KokoroTtsProvider } from './kokoro.js';
31+import { TtsWebuiProvider } from './tts-webui.js';
3132
3233const UPDATE_INTERVAL = 1000;
3334const wrapper = new ModuleWorkerWrapper(moduleWorker);
@@ -105,6 +106,7 @@ const ttsProviders = {
105106 System: SystemTtsProvider,
106107 VITS: VITSTtsProvider,
107108 XTTSv2: XTTSTtsProvider,
109+ 'TTS WebUI': TtsWebuiProvider,
108110};
109111let ttsProvider;
110112let ttsProviderName;
public/scripts/extensions/tts/lib/pcm-processor.js+73 -0
@@ -0,0 +1,73 @@
1+class PCMProcessor extends AudioWorkletProcessor {
2+ constructor() {
3+ super();
4+ this.buffer = new Float32Array(24000 * 30); // Pre-allocate buffer for ~30 seconds at 24kHz
5+ this.writeIndex = 0;
6+ this.readIndex = 0;
7+ this.pendingBytes = new Uint8Array(0); // Buffer for incomplete samples
8+ this.volume = 1.0; // Default volume (1.0 = 100%, 0.5 = 50%, etc.)
9+ this.port.onmessage = (event) => {
10+ if (event.data.pcmData) {
11+ // Combine any pending bytes with new data
12+ const newData = new Uint8Array(event.data.pcmData);
13+ const combined = new Uint8Array(this.pendingBytes.length + newData.length);
14+ combined.set(this.pendingBytes);
15+ combined.set(newData, this.pendingBytes.length);
16+
17+ // Calculate how many complete 16-bit samples we have
18+ const completeSamples = Math.floor(combined.length / 2);
19+ const bytesToProcess = completeSamples * 2;
20+
21+ if (completeSamples > 0) {
22+ // Process complete samples
23+ const int16Array = new Int16Array(combined.buffer.slice(0, bytesToProcess));
24+
25+ // Write directly to circular buffer
26+ for (let i = 0; i < int16Array.length; i++) {
27+ // Expand buffer if needed
28+ if (this.writeIndex >= this.buffer.length) {
29+ const newBuffer = new Float32Array(this.buffer.length * 2);
30+ // Copy existing data maintaining order
31+ let sourceIndex = this.readIndex;
32+ let targetIndex = 0;
33+ while (sourceIndex !== this.writeIndex) {
34+ newBuffer[targetIndex++] = this.buffer[sourceIndex];
35+ sourceIndex = (sourceIndex + 1) % this.buffer.length;
36+ }
37+ this.buffer = newBuffer;
38+ this.readIndex = 0;
39+ this.writeIndex = targetIndex;
40+ }
41+
42+ this.buffer[this.writeIndex] = int16Array[i] / 32768.0; // Convert 16-bit to float
43+ this.writeIndex = (this.writeIndex + 1) % this.buffer.length;
44+ }
45+ }
46+
47+ // Store any remaining incomplete bytes
48+ if (combined.length > bytesToProcess) {
49+ this.pendingBytes = combined.slice(bytesToProcess);
50+ } else {
51+ this.pendingBytes = new Uint8Array(0);
52+ }
53+ } else if (event.data.volume !== undefined) {
54+ // Set volume (0.0 to 1.0, can go higher for amplification)
55+ this.volume = Math.max(0, event.data.volume);
56+ }
57+ };
58+ }
59+
60+ process(inputs, outputs, parameters) {
61+ const output = outputs[0];
62+ if (output.length > 0 && this.readIndex !== this.writeIndex) {
63+ const channelData = output[0];
64+ for (let i = 0; i < channelData.length && this.readIndex !== this.writeIndex; i++) {
65+ channelData[i] = this.buffer[this.readIndex] * this.volume;
66+ this.readIndex = (this.readIndex + 1) % this.buffer.length;
67+ }
68+ }
69+ return true;
70+ }
71+}
72+
73+registerProcessor('pcm-processor', PCMProcessor);
public/scripts/extensions/tts/tts-webui.js+570 -0
@@ -0,0 +1,570 @@
1+import { getPreviewString, saveTtsProviderSettings } from './index.js';
2+
3+export { TtsWebuiProvider };
4+
5+class TtsWebuiProvider {
6+ settings;
7+ voices = [];
8+ separator = ' . ';
9+
10+ audioElement = document.createElement('audio');
11+ audioContext = null;
12+ audioWorkletNode = null;
13+ currentVolume = 1.0; // Track current volume
14+
15+ defaultSettings = {
16+ voiceMap: {},
17+ model: 'chatterbox',
18+ speed: 1,
19+ volume: 1.0,
20+ available_voices: [''],
21+ provider_endpoint: 'http://127.0.0.1:7778/v1/audio/speech',
22+ streaming: true,
23+ stream_chunk_size: 100,
24+ desired_length: 80,
25+ max_length: 200,
26+ halve_first_chunk: true,
27+ exaggeration: 0.5,
28+ cfg_weight: 0.5,
29+ temperature: 0.8,
30+ device: 'auto',
31+ dtype: 'float32',
32+ cpu_offload: false,
33+ chunked: true,
34+ cache_voice: false,
35+ tokens_per_slice: 1000,
36+ remove_milliseconds: 45,
37+ remove_milliseconds_start: 25,
38+ chunk_overlap_method: 'zero',
39+ seed: -1,
40+ };
41+
42+ get settingsHtml() {
43+ let html = `
44+ <h4 class="textAlignCenter">TTS WebUI Settings</h4>
45+
46+ <div class="flex gap10px marginBot10 alignItemsFlexEnd">
47+ <div class="flex1 flexFlowColumn">
48+ <label for="tts_webui_endpoint">Provider Endpoint:</label>
49+ <input id="tts_webui_endpoint" type="text" class="text_pole" maxlength="500" value="${this.defaultSettings.provider_endpoint}"/>
50+ </div>
51+ <div id="tts_webui_key" class="menu_button menu_button_icon padding10">
52+ <i class="fa-solid fa-key"></i>
53+ <span>API Key</span>
54+ </div>
55+ </div>
56+
57+ <div class="flex gap10px marginBot10">
58+ <div class="flex1 flexFlowColumn">
59+ <label for="tts_webui_model">Model:</label>
60+ <input id="tts_webui_model" type="text" class="text_pole" maxlength="500" value="${this.defaultSettings.model}"/>
61+ </div>
62+ <div class="flex1 flexFlowColumn">
63+ <label for="tts_webui_voices">Available Voices (comma separated):</label>
64+ <input id="tts_webui_voices" type="text" class="text_pole" value="${this.defaultSettings.available_voices.join()}"/>
65+ </div>
66+ </div>
67+
68+ <div class="flex gap10px marginBot10">
69+ <div class="flex1 flexFlowColumn">
70+ <label for="tts_webui_streaming" class="checkbox_label alignItemsCenter flexGap5">
71+ <input id="tts_webui_streaming" type="checkbox" />
72+ <span>Streaming</span>
73+ </label>
74+ </div>
75+ <div class="flex1 flexFlowColumn">
76+ <label for="tts_webui_volume">Volume: <span id="tts_webui_volume_output">${this.defaultSettings.volume}</span></label>
77+ <input type="range" id="tts_webui_volume" value="${this.defaultSettings.volume}" min="0" max="2" step="0.1">
78+ </div>
79+ </div>
80+
81+ <hr>
82+ <h4 class="textAlignCenter">Generation Settings</h4>
83+
84+ <div class="flex gap10px marginBot10">
85+ <div class="flex1 flexFlowColumn">
86+ <label for="tts_webui_exaggeration">Exaggeration: <span id="tts_webui_exaggeration_output">${this.defaultSettings.exaggeration}</span></label>
87+ <input id="tts_webui_exaggeration" type="range" value="${this.defaultSettings.exaggeration}" min="0" max="2" step="0.1" />
88+ </div>
89+ <div class="flex1 flexFlowColumn">
90+ <label for="tts_webui_cfg_weight">CFG Weight: <span id="tts_webui_cfg_weight_output">${this.defaultSettings.cfg_weight}</span></label>
91+ <input id="tts_webui_cfg_weight" type="range" value="${this.defaultSettings.cfg_weight}" min="0" max="2" step="0.1" />
92+ </div>
93+ </div>
94+
95+ <div class="flex gap10px marginBot10">
96+ <div class="flex1 flexFlowColumn">
97+ <label for="tts_webui_temperature">Temperature: <span id="tts_webui_temperature_output">${this.defaultSettings.temperature}</span></label>
98+ <input id="tts_webui_temperature" type="range" value="${this.defaultSettings.temperature}" min="0" max="2" step="0.1" />
99+ </div>
100+ <div class="flex1 flexFlowColumn">
101+ <label for="tts_webui_seed">Seed (-1 for random):</label>
102+ <input id="tts_webui_seed" type="text" class="text_pole" value="${this.defaultSettings.seed}"/>
103+ </div>
104+ </div>
105+
106+ <hr>
107+ <h4 class="textAlignCenter">Chunking</h4>
108+
109+ <div class="flex gap10px marginBot10">
110+ <div class="flex1 flexFlowColumn">
111+ <label for="tts_webui_chunked" class="checkbox_label alignItemsCenter flexGap5">
112+ <input id="tts_webui_chunked" type="checkbox" />
113+ <span>Split prompt into chunks</span>
114+ </label>
115+ </div>
116+ <div class="flex1 flexFlowColumn">
117+ <label for="tts_webui_halve_first_chunk" class="checkbox_label alignItemsCenter flexGap5">
118+ <input id="tts_webui_halve_first_chunk" type="checkbox" />
119+ <span>Halve First Chunk</span>
120+ </label>
121+ </div>
122+ </div>
123+
124+ <div class="flex gap10px marginBot10">
125+ <div class="flex1 flexFlowColumn">
126+ <label for="tts_webui_desired_length">Desired Length: <span id="tts_webui_desired_length_output">${this.defaultSettings.desired_length}</span></label>
127+ <input id="tts_webui_desired_length" type="range" value="${this.defaultSettings.desired_length}" min="25" max="300" step="5" />
128+ </div>
129+ <div class="flex1 flexFlowColumn">
130+ <label for="tts_webui_max_length">Max Length: <span id="tts_webui_max_length_output">${this.defaultSettings.max_length}</span></label>
131+ <input id="tts_webui_max_length" type="range" value="${this.defaultSettings.max_length}" min="50" max="450" step="5" />
132+ </div>
133+ </div>
134+
135+ <hr>
136+ <h4 class="textAlignCenter">Model</h4>
137+
138+ <div class="flex gap10px marginBot10">
139+ <div class="flex1 flexFlowColumn">
140+ <label for="tts_webui_device">Device:</label>
141+ <select id="tts_webui_device">
142+ <option value="auto" ${this.defaultSettings.device === 'auto' ? 'selected' : ''}>Auto</option>
143+ <option value="cuda" ${this.defaultSettings.device === 'cuda' ? 'selected' : ''}>CUDA</option>
144+ <option value="mps" ${this.defaultSettings.device === 'mps' ? 'selected' : ''}>MPS</option>
145+ <option value="cpu" ${this.defaultSettings.device === 'cpu' ? 'selected' : ''}>CPU</option>
146+ </select>
147+ </div>
148+ <div class="flex1 flexFlowColumn">
149+ <label for="tts_webui_dtype">Data Type:</label>
150+ <select id="tts_webui_dtype">
151+ <option value="float32" ${this.defaultSettings.dtype === 'float32' ? 'selected' : ''}>Float32</option>
152+ <option value="float16" ${this.defaultSettings.dtype === 'float16' ? 'selected' : ''}>Float16</option>
153+ <option value="bfloat16" ${this.defaultSettings.dtype === 'bfloat16' ? 'selected' : ''}>BFloat16</option>
154+ </select>
155+ </div>
156+ </div>
157+
158+ <div class="flex gap10px marginBot10">
159+ <div class="flex1 flexFlowColumn">
160+ <label for="tts_webui_cpu_offload" class="checkbox_label alignItemsCenter flexGap5">
161+ <input id="tts_webui_cpu_offload" type="checkbox" />
162+ <span>CPU Offload</span>
163+ </label>
164+ </div>
165+ <div class="flex1">
166+ <!-- Empty for spacing -->
167+ </div>
168+ </div>
169+
170+ <hr>
171+ <h4 class="textAlignCenter">Streaming (Advanced Settings)</h4>
172+
173+ <div class="flex gap10px marginBot10">
174+ <div class="flex1 flexFlowColumn">
175+ <label for="tts_webui_tokens_per_slice">Tokens Per Slice: <span id="tts_webui_tokens_per_slice_output">${this.defaultSettings.tokens_per_slice}</span></label>
176+ <input id="tts_webui_tokens_per_slice" type="range" value="${this.defaultSettings.tokens_per_slice}" min="15" max="1000" step="1" />
177+ </div>
178+ <div class="flex1 flexFlowColumn">
179+ <label for="tts_webui_chunk_overlap_method">Chunk Overlap Method:</label>
180+ <select id="tts_webui_chunk_overlap_method">
181+ <option value="zero" ${this.defaultSettings.chunk_overlap_method === 'zero' ? 'selected' : ''}>Zero</option>
182+ <option value="full" ${this.defaultSettings.chunk_overlap_method === 'full' ? 'selected' : ''}>Full</option>
183+ </select>
184+ </div>
185+ </div>
186+
187+ <div class="flex gap10px marginBot10">
188+ <div class="flex1 flexFlowColumn">
189+ <label for="tts_webui_remove_milliseconds">Remove Milliseconds: <span id="tts_webui_remove_milliseconds_output">${this.defaultSettings.remove_milliseconds}</span></label>
190+ <input id="tts_webui_remove_milliseconds" type="range" value="${this.defaultSettings.remove_milliseconds}" min="0" max="100" step="1" />
191+ </div>
192+ <div class="flex1 flexFlowColumn">
193+ <label for="tts_webui_remove_milliseconds_start">Remove Milliseconds Start: <span id="tts_webui_remove_milliseconds_start_output">${this.defaultSettings.remove_milliseconds_start}</span></label>
194+ <input id="tts_webui_remove_milliseconds_start" type="range" value="${this.defaultSettings.remove_milliseconds_start}" min="0" max="100" step="1" />
195+ </div>
196+ </div>`;
197+ return html;
198+ }
199+
200+ async loadSettings(settings) {
201+ // Populate Provider UI given input settings
202+ if (Object.keys(settings).length == 0) {
203+ console.info('Using default TTS Provider settings');
204+ }
205+
206+ // Only accept keys defined in defaultSettings
207+ this.settings = this.defaultSettings;
208+
209+ for (const key in settings) {
210+ if (key in this.settings) {
211+ this.settings[key] = settings[key];
212+ } else {
213+ throw `Invalid setting passed to TTS Provider: ${key}`;
214+ }
215+ }
216+
217+ $('#tts_webui_endpoint').val(this.settings.provider_endpoint);
218+ $('#tts_webui_endpoint').on('input', () => { this.onSettingsChange(); });
219+
220+ $('#tts_webui_model').val(this.settings.model);
221+ $('#tts_webui_model').on('input', () => { this.onSettingsChange(); });
222+
223+ $('#tts_webui_voices').val(this.settings.available_voices.join());
224+ $('#tts_webui_voices').on('input', () => { this.onSettingsChange(); });
225+
226+ $('#tts_webui_streaming').prop('checked', this.settings.streaming);
227+ $('#tts_webui_streaming').on('change', () => { this.onSettingsChange(); });
228+
229+ $('#tts_webui_volume').val(this.settings.volume);
230+ $('#tts_webui_volume').on('input', () => {
231+ this.onSettingsChange();
232+ });
233+
234+ $('#tts_webui_stream_chunk_size').val(this.settings.stream_chunk_size);
235+ $('#tts_webui_stream_chunk_size').on('input', () => { this.onSettingsChange(); });
236+
237+ $('#tts_webui_desired_length').val(this.settings.desired_length);
238+ $('#tts_webui_desired_length').on('input', () => { this.onSettingsChange(); });
239+
240+ $('#tts_webui_max_length').val(this.settings.max_length);
241+ $('#tts_webui_max_length').on('input', () => { this.onSettingsChange(); });
242+
243+ $('#tts_webui_halve_first_chunk').prop('checked', this.settings.halve_first_chunk);
244+ $('#tts_webui_halve_first_chunk').on('change', () => { this.onSettingsChange(); });
245+
246+ $('#tts_webui_exaggeration').val(this.settings.exaggeration);
247+ $('#tts_webui_exaggeration').on('input', () => { this.onSettingsChange(); });
248+
249+ $('#tts_webui_cfg_weight').val(this.settings.cfg_weight);
250+ $('#tts_webui_cfg_weight').on('input', () => { this.onSettingsChange(); });
251+
252+ $('#tts_webui_temperature').val(this.settings.temperature);
253+ $('#tts_webui_temperature').on('input', () => { this.onSettingsChange(); });
254+
255+ $('#tts_webui_device').val(this.settings.device);
256+ $('#tts_webui_device').on('change', () => { this.onSettingsChange(); });
257+
258+ $('#tts_webui_dtype').val(this.settings.dtype);
259+ $('#tts_webui_dtype').on('change', () => { this.onSettingsChange(); });
260+
261+ $('#tts_webui_cpu_offload').prop('checked', this.settings.cpu_offload);
262+ $('#tts_webui_cpu_offload').on('change', () => { this.onSettingsChange(); });
263+
264+ $('#tts_webui_chunked').prop('checked', this.settings.chunked);
265+ $('#tts_webui_chunked').on('change', () => { this.onSettingsChange(); });
266+
267+ $('#tts_webui_tokens_per_slice').val(this.settings.tokens_per_slice);
268+ $('#tts_webui_tokens_per_slice').on('input', () => { this.onSettingsChange(); });
269+
270+ $('#tts_webui_remove_milliseconds').val(this.settings.remove_milliseconds);
271+ $('#tts_webui_remove_milliseconds').on('input', () => { this.onSettingsChange(); });
272+
273+ $('#tts_webui_remove_milliseconds_start').val(this.settings.remove_milliseconds_start);
274+ $('#tts_webui_remove_milliseconds_start').on('input', () => { this.onSettingsChange(); });
275+
276+ $('#tts_webui_chunk_overlap_method').val(this.settings.chunk_overlap_method);
277+ $('#tts_webui_chunk_overlap_method').on('change', () => { this.onSettingsChange(); });
278+
279+ $('#tts_webui_seed').val(this.settings.seed);
280+ $('#tts_webui_seed').on('input', () => { this.onSettingsChange(); });
281+
282+ // Update output labels
283+ $('#tts_webui_volume_output').text(this.settings.volume);
284+ $('#tts_webui_desired_length_output').text(this.settings.desired_length);
285+ $('#tts_webui_max_length_output').text(this.settings.max_length);
286+ $('#tts_webui_exaggeration_output').text(this.settings.exaggeration);
287+ $('#tts_webui_cfg_weight_output').text(this.settings.cfg_weight);
288+ $('#tts_webui_temperature_output').text(this.settings.temperature);
289+ $('#tts_webui_tokens_per_slice_output').text(this.settings.tokens_per_slice);
290+ $('#tts_webui_remove_milliseconds_output').text(this.settings.remove_milliseconds);
291+ $('#tts_webui_remove_milliseconds_start_output').text(this.settings.remove_milliseconds_start);
292+
293+ await this.checkReady();
294+
295+ console.debug('OpenAI Compatible TTS: Settings loaded');
296+ }
297+
298+ onSettingsChange() {
299+ // Update dynamically
300+ this.settings.provider_endpoint = String($('#tts_webui_endpoint').val());
301+ this.settings.model = String($('#tts_webui_model').val());
302+ this.settings.available_voices = String($('#tts_webui_voices').val()).split(',');
303+ this.settings.volume = Number($('#tts_webui_volume').val());
304+ this.settings.streaming = $('#tts_webui_streaming').is(':checked');
305+ this.settings.stream_chunk_size = Number($('#tts_webui_stream_chunk_size').val());
306+ this.settings.desired_length = Number($('#tts_webui_desired_length').val());
307+ this.settings.max_length = Number($('#tts_webui_max_length').val());
308+ this.settings.halve_first_chunk = $('#tts_webui_halve_first_chunk').is(':checked');
309+ this.settings.exaggeration = Number($('#tts_webui_exaggeration').val());
310+ this.settings.cfg_weight = Number($('#tts_webui_cfg_weight').val());
311+ this.settings.temperature = Number($('#tts_webui_temperature').val());
312+ this.settings.device = String($('#tts_webui_device').val());
313+ this.settings.dtype = String($('#tts_webui_dtype').val());
314+ this.settings.cpu_offload = $('#tts_webui_cpu_offload').is(':checked');
315+ this.settings.chunked = $('#tts_webui_chunked').is(':checked');
316+ this.settings.tokens_per_slice = Number($('#tts_webui_tokens_per_slice').val());
317+ this.settings.remove_milliseconds = Number($('#tts_webui_remove_milliseconds').val());
318+ this.settings.remove_milliseconds_start = Number($('#tts_webui_remove_milliseconds_start').val());
319+ this.settings.chunk_overlap_method = String($('#tts_webui_chunk_overlap_method').val());
320+ this.settings.seed = parseInt($('#tts_webui_seed').val()) || -1;
321+
322+ // Apply volume change immediately
323+ this.setVolume(this.settings.volume);
324+
325+ // Update output labels
326+ $('#tts_webui_volume_output').text(this.settings.volume);
327+ $('#tts_webui_desired_length_output').text(this.settings.desired_length);
328+ $('#tts_webui_max_length_output').text(this.settings.max_length);
329+ $('#tts_webui_exaggeration_output').text(this.settings.exaggeration);
330+ $('#tts_webui_cfg_weight_output').text(this.settings.cfg_weight);
331+ $('#tts_webui_temperature_output').text(this.settings.temperature);
332+ $('#tts_webui_tokens_per_slice_output').text(this.settings.tokens_per_slice);
333+ $('#tts_webui_remove_milliseconds_output').text(this.settings.remove_milliseconds);
334+ $('#tts_webui_remove_milliseconds_start_output').text(this.settings.remove_milliseconds_start);
335+
336+ saveTtsProviderSettings();
337+ }
338+
339+ async checkReady() {
340+ await this.fetchTtsVoiceObjects();
341+ }
342+
343+ async onRefreshClick() {
344+ await this.fetchTtsVoiceObjects();
345+ console.info('TTS voices refreshed');
346+ }
347+
348+ async getVoice(voiceName) {
349+ if (this.voices.length == 0) {
350+ this.voices = await this.fetchTtsVoiceObjects();
351+ }
352+ const match = this.voices.filter(
353+ oaicVoice => oaicVoice.name == voiceName,
354+ )[0];
355+ if (!match) {
356+ throw `TTS Voice name ${voiceName} not found`;
357+ }
358+ return match;
359+ }
360+
361+ async generateTts(text, voiceId) {
362+ const response = await this.fetchTtsGeneration(text, voiceId);
363+
364+ if (this.settings.streaming) {
365+ // Stream audio in real-time
366+ await this.processStreamingAudio(response);
367+ // Return empty string since audio is already played via AudioWorklet
368+ return '';
369+ }
370+
371+ return response;
372+ }
373+
374+ async fetchTtsVoiceObjects() {
375+ // Try to fetch voices from the provider endpoint
376+ try {
377+ const voicesEndpoint = this.settings.provider_endpoint.replace('/speech', '/voices/' + this.settings.model);
378+ const response = await fetch(voicesEndpoint);
379+
380+ if (!response.ok) {
381+ throw new Error(`HTTP ${response.status}`);
382+ }
383+
384+ const responseJson = await response.json();
385+ console.info('Discovered voices from provider:', responseJson);
386+
387+ this.voices = responseJson.voices.map(({ value, label }) => ({
388+ name: label,
389+ voice_id: value,
390+ lang: 'en-US',
391+ }));
392+
393+ return this.voices;
394+ } catch (error) {
395+ console.warn('Voice discovery failed, using configured voices:', error);
396+ }
397+
398+ // Fallback to configured voices
399+ this.voices = this.settings.available_voices.map(name => ({
400+ name, voice_id: name, lang: 'en-US',
401+ }));
402+
403+ return this.voices;
404+ }
405+
406+ async initAudioWorklet(wavSampleRate) {
407+ this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: wavSampleRate });
408+
409+ // Load the PCM processor from separate file
410+ const processorUrl = './scripts/extensions/tts/lib/pcm-processor.js';
411+ await this.audioContext.audioWorklet.addModule(processorUrl);
412+ this.audioWorkletNode = new AudioWorkletNode(this.audioContext, 'pcm-processor');
413+ this.audioWorkletNode.connect(this.audioContext.destination);
414+ }
415+
416+ parseWavHeader(buffer) {
417+ const view = new DataView(buffer);
418+ // Sample rate is at bytes 24-27 (little endian)
419+ const sampleRate = view.getUint32(24, true);
420+ // Number of channels is at bytes 22-23 (little endian)
421+ const channels = view.getUint16(22, true);
422+ // Bits per sample is at bytes 34-35 (little endian)
423+ const bitsPerSample = view.getUint16(34, true);
424+
425+ return { sampleRate, channels, bitsPerSample };
426+ }
427+
428+ async processStreamingAudio(response) {
429+ if (!response.ok) {
430+ throw new Error(`HTTP ${response.status}`);
431+ }
432+
433+ const reader = response.body.getReader();
434+ let headerParsed = false;
435+ let wavInfo = null;
436+
437+ const processStream = async ({ done, value }) => {
438+ if (done) {
439+ return;
440+ }
441+
442+ if (!headerParsed) {
443+ // Parse WAV header to get sample rate
444+ wavInfo = this.parseWavHeader(value.buffer);
445+ console.log('WAV Info:', wavInfo);
446+
447+ // Initialize AudioWorklet with correct sample rate
448+ await this.initAudioWorklet(wavInfo.sampleRate);
449+
450+ // Skip WAV header (first 44 bytes typically)
451+ const pcmData = value.slice(44);
452+ this.audioWorkletNode.port.postMessage({ pcmData });
453+ headerParsed = true;
454+
455+ const next = await reader.read();
456+ return processStream(next);
457+ }
458+
459+ // Send PCM data to AudioWorklet for immediate playback
460+ this.audioWorkletNode.port.postMessage({ pcmData: value });
461+ const next = await reader.read();
462+ return processStream(next);
463+ };
464+
465+ const firstChunk = await reader.read();
466+ await processStream(firstChunk);
467+ }
468+
469+ async previewTtsVoice(voiceId) {
470+ this.audioElement.pause();
471+ this.audioElement.currentTime = 0;
472+
473+ const text = getPreviewString('en-US');
474+ const response = await this.fetchTtsGeneration(text, voiceId);
475+
476+ if (this.settings.streaming) {
477+ // Use shared streaming method
478+ await this.processStreamingAudio(response);
479+ } else {
480+ // For non-streaming, response is a fetch Response object
481+ if (!response.ok) {
482+ throw new Error(`HTTP ${response.status}`);
483+ }
484+
485+ const audio = await response.blob();
486+ const url = URL.createObjectURL(audio);
487+ this.audioElement.src = url;
488+ this.audioElement.play();
489+ this.audioElement.onended = () => URL.revokeObjectURL(url);
490+ }
491+ }
492+
493+ async fetchTtsGeneration(inputText, voiceId) {
494+ console.info(`Generating new TTS for voice_id ${voiceId}`);
495+
496+ const settings = this.settings;
497+ const streaming = settings.streaming;
498+
499+ const chatterboxParams = [
500+ 'desired_length',
501+ 'max_length',
502+ 'halve_first_chunk',
503+ 'exaggeration',
504+ 'cfg_weight',
505+ 'temperature',
506+ 'device',
507+ 'dtype',
508+ 'cpu_offload',
509+ 'chunked',
510+ 'cache_voice',
511+ 'tokens_per_slice',
512+ 'remove_milliseconds',
513+ 'remove_milliseconds_start',
514+ 'chunk_overlap_method',
515+ 'seed',
516+ ];
517+ const getParams = settings => Object.fromEntries(
518+ Object.entries(settings).filter(([key]) =>
519+ chatterboxParams.includes(key),
520+ ),
521+ );
522+
523+ const requestBody = {
524+ model: settings.model,
525+ voice: voiceId,
526+ input: inputText,
527+ response_format: 'wav',
528+ speed: settings.speed,
529+ stream: streaming,
530+ params: getParams(settings),
531+ };
532+
533+ const headers = {
534+ 'Content-Type': 'application/json',
535+ 'Cache-Control': streaming ? 'no-cache' : undefined,
536+ };
537+
538+ if (streaming) {
539+ headers['Cache-Control'] = 'no-cache';
540+ }
541+
542+ const response = await fetch(settings.provider_endpoint, {
543+ method: 'POST',
544+ headers,
545+ body: JSON.stringify(requestBody),
546+ });
547+
548+ if (!response.ok) {
549+ toastr.error(response.statusText, 'TTS Generation Failed');
550+ throw new Error(
551+ `HTTP ${response.status}: ${await response.text()}`,
552+ );
553+ }
554+
555+ return response;
556+ }
557+
558+ setVolume(volume) {
559+ // Clamp volume between 0.0 and 2.0 (0% to 200%)
560+ this.currentVolume = Math.max(0, Math.min(2.0, volume));
561+
562+ // Set volume for regular audio element (non-streaming)
563+ this.audioElement.volume = Math.min(this.currentVolume, 1.0); // HTML audio element max is 1.0
564+
565+ // Set volume for AudioWorklet (streaming)
566+ if (this.audioWorkletNode) {
567+ this.audioWorkletNode.port.postMessage({ volume: this.currentVolume });
568+ }
569+ }
570+}