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.
28import { POPUP_TYPE, callGenericPopup } from '../../popup.js';28import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
29import { GoogleTranslateTtsProvider } from './google-translate.js';29import { GoogleTranslateTtsProvider } from './google-translate.js';
30import { KokoroTtsProvider } from './kokoro.js';30import { KokoroTtsProvider } from './kokoro.js';
31import { TtsWebuiProvider } from './tts-webui.js';
3132
32const UPDATE_INTERVAL = 1000;33const UPDATE_INTERVAL = 1000;
33const wrapper = new ModuleWorkerWrapper(moduleWorker);34const wrapper = new ModuleWorkerWrapper(moduleWorker);
@@ -105,6 +106,7 @@ const ttsProviders = {
105 System: SystemTtsProvider,106 System: SystemTtsProvider,
106 VITS: VITSTtsProvider,107 VITS: VITSTtsProvider,
107 XTTSv2: XTTSTtsProvider,108 XTTSv2: XTTSTtsProvider,
109 'TTS WebUI': TtsWebuiProvider,
108};110};
109let ttsProvider;111let ttsProvider;
110let ttsProviderName;112let ttsProviderName;
public/scripts/extensions/tts/lib/pcm-processor.js+73 -0
@@ -0,0 +1,73 @@
1class 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
73registerProcessor('pcm-processor', PCMProcessor);
public/scripts/extensions/tts/tts-webui.js+570 -0
@@ -0,0 +1,570 @@
1import { getPreviewString, saveTtsProviderSettings } from './index.js';
2
3export { TtsWebuiProvider };
4
5class 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}