`;
+
+ return html;
+ }
+
+
+ //#################//
+ // Startup ST & AT //
+ //#################//
+
+ async loadSettings(settings) {
+ updateStatus('Offline');
+ // Use default settings if no settings are provided
+ this.settings = Object.keys(settings).length === 0 ? this.Settings : settings;
+
+ try {
+ // Check if TTS provider is ready
+ await this.checkReady();
+ await this.fetchTtsVoiceObjects(); // Fetch voices only if service is ready
+ await this.updateSettingsFromServer();
+ this.updateNarratorVoicesDropdown();
+ this.updateLanguageDropdown();
+ this.setupEventListeners();
+ this.applySettingsToHTML();
+ updateStatus('Ready');
+ } catch (error) {
+ console.error("Error loading settings:", error);
+ // Set status to Error if there is an issue in loading settings
+ updateStatus('Offline');
+ }
+ }
+
+ applySettingsToHTML() {
+ // Load AllTalk specific settings first
+ const loadedSettings = loadAllTalkSettings();
+ // Apply loaded settings or use defaults
+ this.settings = loadedSettings ? { ...this.Settings, ...loadedSettings } : this.Settings;
+ const narratorVoiceSelect = document.getElementById('narrator_voice');
+ const atNarratorSelect = document.getElementById('at_narrator_enabled');
+ const textNotInsideSelect = document.getElementById('at_narrator_text_not_inside');
+ const generationMethodSelect = document.getElementById('at_generation_method');
+ // Apply settings to Narrator Voice dropdown
+ if (narratorVoiceSelect && this.settings.narrator_voice) {
+ narratorVoiceSelect.value = this.settings.narrator_voice.replace('.wav', '');
+ }
+ // Apply settings to AT Narrator Enabled dropdown
+ if (atNarratorSelect) {
+ atNarratorSelect.value = this.settings.narrator_enabled.toString();
+ }
+ // Apply settings to the Language dropdown
+ const languageSelect = document.getElementById('language_options');
+ if (languageSelect && this.settings.language) {
+ languageSelect.value = this.settings.language;
+ }
+ // Apply settings to Text Not Inside dropdown
+ if (textNotInsideSelect && this.settings.text_not_inside) {
+ textNotInsideSelect.value = this.settings.text_not_inside;
+ }
+ // Apply settings to Generation Method dropdown
+ if (generationMethodSelect && this.settings.at_generation_method) {
+ generationMethodSelect.value = this.settings.at_generation_method;
+ }
+ // Additional logic to disable/enable dropdowns based on the selected generation method
+ const isStreamingEnabled = this.settings.at_generation_method === 'streaming_enabled';
+ if (isStreamingEnabled) {
+ // Disable certain dropdowns when streaming is enabled
+ if (atNarratorSelect) atNarratorSelect.disabled = true;
+ if (textNotInsideSelect) textNotInsideSelect.disabled = true;
+ if (narratorVoiceSelect) narratorVoiceSelect.disabled = true;
+ } else {
+ // Enable dropdowns for standard generation
+ if (atNarratorSelect) atNarratorSelect.disabled = false;
+ if (textNotInsideSelect) textNotInsideSelect.disabled = !this.settings.narrator_enabled;
+ if (narratorVoiceSelect) narratorVoiceSelect.disabled = !this.settings.narrator_enabled;
+ }
+ const modelSelect = document.getElementById('switch_model');
+ if (this.settings.finetuned_model === 'true') {
+ const ftOption = document.createElement('option');
+ ftOption.value = 'XTTSv2 FT';
+ ftOption.textContent = 'XTTSv2 FT';
+ modelSelect.appendChild(ftOption);
+ }
+ //console.log("Settings applied to HTML.");
+ }
+
+ //##############################//
+ // Check AT Server is Available //
+ //##############################//
+
+ async checkReady() {
+ try {
+ const response = await fetch(`${this.settings.provider_endpoint}/api/ready`);
+ // Check if the HTTP request was successful
+ if (!response.ok) {
+ throw new Error(`HTTP Error Response: ${response.status} ${response.statusText}`);
+ }
+ const statusText = await response.text();
+ // Check if the response is 'Ready'
+ if (statusText === 'Ready') {
+ this.ready = true; // Set the ready flag to true
+ console.log('TTS service is ready.');
+ } else {
+ this.ready = false;
+ console.log('TTS service is not ready.');
+ }
+ } catch (error) {
+ console.error('Error checking TTS service readiness:', error);
+ this.ready = false; // Ensure ready flag is set to false in case of error
+ }
+ }
+
+ //######################//
+ // Get Available Voices //
+ //######################//
+
+ async fetchTtsVoiceObjects() {
+ const response = await fetch(`${this.settings.provider_endpoint}/api/voices`);
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
+ }
+ const data = await response.json();
+ const voices = data.voices.map(filename => {
+ const voiceName = filename.replace('.wav', '');
+ return {
+ name: voiceName,
+ voice_id: voiceName,
+ preview_url: null, // Preview URL will be dynamically generated
+ lang: 'en' // Default language
+ };
+ });
+ this.voices = voices; // Assign to the class property
+ return voices; // Also return this list
+ }
+
+ //##########################################//
+ // Get Current AT Server Config & Update ST //
+ //##########################################//
+
+ async updateSettingsFromServer() {
+ try {
+ // Fetch current settings
+ const response = await fetch(`${this.settings.provider_endpoint}/api/currentsettings`);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch current settings: ${response.statusText}`);
+ }
+ const currentSettings = await response.json();
+ // Update internal settings
+ this.settings.modelsAvailable = currentSettings.models_available;
+ this.settings.currentModel = currentSettings.current_model_loaded;
+ this.settings.deepspeed_available = currentSettings.deepspeed_available;
+ this.settings.deepSpeedEnabled = currentSettings.deepspeed_status;
+ this.settings.lowVramEnabled = currentSettings.low_vram_status;
+ this.settings.finetuned_model = currentSettings.finetuned_model
+ // Update HTML elements
+ this.updateModelDropdown();
+ this.updateCheckboxes();
+ } catch (error) {
+ console.error(`Error updating settings from server: ${error}`);
+ }
+ }
+
+ //###################################################//
+ // Get Current AT Server Config & Update ST (Models) //
+ //###################################################//
+
+ updateModelDropdown() {
+ const modelSelect = document.getElementById('switch_model');
+ if (modelSelect) {
+ modelSelect.innerHTML = ''; // Clear existing options
+ this.settings.modelsAvailable.forEach(model => {
+ const option = document.createElement('option');
+ option.value = model.model_name;
+ option.textContent = model.model_name; // Use model_name instead of name
+ option.selected = model.model_name === this.settings.currentModel;
+ modelSelect.appendChild(option);
+ });
+ }
+ }
+
+ //#######################################################//
+ // Get Current AT Server Config & Update ST (DS and LVR) //
+ //#######################################################//
+
+ updateCheckboxes() {
+ const deepspeedCheckbox = document.getElementById('deepspeed');
+ const lowVramCheckbox = document.getElementById('low_vram');
+ if (lowVramCheckbox) lowVramCheckbox.checked = this.settings.lowVramEnabled;
+ if (deepspeedCheckbox) {
+ deepspeedCheckbox.checked = this.settings.deepSpeedEnabled;
+ deepspeedCheckbox.disabled = !this.settings.deepspeed_available; // Disable checkbox if deepspeed is not available
+ }
+ }
+
+ //###############################################################//
+ // Get Current AT Server Config & Update ST (AT Narrator Voices) //
+ //###############################################################//
+
+ updateNarratorVoicesDropdown() {
+ const narratorVoiceSelect = document.getElementById('narrator_voice');
+ if (narratorVoiceSelect && this.voices) {
+ // Clear existing options
+ narratorVoiceSelect.innerHTML = '';
+ // Add new options
+ for (let voice of this.voices) {
+ const option = document.createElement('option');
+ option.value = voice.voice_id;
+ option.textContent = voice.name;
+ narratorVoiceSelect.appendChild(option);
+ }
+ }
+ }
+
+ //######################################################//
+ // Get Current AT Server Config & Update ST (Languages) //
+ //######################################################//
+
+ updateLanguageDropdown() {
+ const languageSelect = document.getElementById('language_options');
+ if (languageSelect) {
+ // Ensure default language is set
+ this.settings.language = this.Settings.language;
+
+ languageSelect.innerHTML = '';
+ for (let language in this.languageLabels) {
+ const option = document.createElement('option');
+ option.value = this.languageLabels[language];
+ option.textContent = language;
+ if (this.languageLabels[language] === this.settings.language) {
+ option.selected = true;
+ }
+ languageSelect.appendChild(option);
+ }
+ }
+ }
+
+ //########################################//
+ // Start AT TTS extenstion page listeners //
+ //########################################//
+
+ setupEventListeners() {
+
+ let debounceTimeout;
+ const debounceDelay = 500; // Milliseconds
+
+ // Define the event handler function
+ const onModelSelectChange = async (event) => {
+ console.log("Model select change event triggered"); // Debugging statement
+ const selectedModel = event.target.value;
+ console.log(`Selected model: ${selectedModel}`); // Debugging statement
+ // Set status to Processing
+ updateStatus('Processing');
+ try {
+ const response = await fetch(`${this.settings.provider_endpoint}/api/reload?tts_method=${encodeURIComponent(selectedModel)}`, {
+ method: 'POST'
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP Error: ${response.status}`);
+ }
+ const data = await response.json();
+ console.log("POST response data:", data); // Debugging statement
+ // Set status to Ready if successful
+ updateStatus('Ready');
+ } catch (error) {
+ console.error("POST request error:", error); // Debugging statement
+ // Set status to Error in case of failure
+ updateStatus('Error');
+ }
+
+ // Handle response or error
+ };
+
+ const debouncedModelSelectChange = (event) => {
+ clearTimeout(debounceTimeout);
+ debounceTimeout = setTimeout(() => {
+ onModelSelectChange(event);
+ }, debounceDelay);
+ };
+
+ // Switch Model Listener
+ const modelSelect = document.getElementById('switch_model');
+ if (modelSelect) {
+ // Remove the event listener if it was previously added
+ modelSelect.removeEventListener('change', debouncedModelSelectChange);
+ // Add the debounced event listener
+ modelSelect.addEventListener('change', debouncedModelSelectChange);
+ }
+
+ // DeepSpeed Listener
+ const deepspeedCheckbox = document.getElementById('deepspeed');
+ if (deepspeedCheckbox) {
+ deepspeedCheckbox.addEventListener('change', async (event) => {
+ const deepSpeedValue = event.target.checked ? 'True' : 'False';
+ // Set status to Processing
+ updateStatus('Processing');
+ try {
+ const response = await fetch(`${this.settings.provider_endpoint}/api/deepspeed?new_deepspeed_value=${deepSpeedValue}`, {
+ method: 'POST'
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP Error: ${response.status}`);
+ }
+ const data = await response.json();
+ console.log("POST response data:", data); // Debugging statement
+ // Set status to Ready if successful
+ updateStatus('Ready');
+ } catch (error) {
+ console.error("POST request error:", error); // Debugging statement
+ // Set status to Error in case of failure
+ updateStatus('Error');
+ }
+ });
+ }
+
+ // Low VRAM Listener
+ const lowVramCheckbox = document.getElementById('low_vram');
+ if (lowVramCheckbox) {
+ lowVramCheckbox.addEventListener('change', async (event) => {
+ const lowVramValue = event.target.checked ? 'True' : 'False';
+ // Set status to Processing
+ updateStatus('Processing');
+ try {
+ const response = await fetch(`${this.settings.provider_endpoint}/api/lowvramsetting?new_low_vram_value=${lowVramValue}`, {
+ method: 'POST'
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP Error: ${response.status}`);
+ }
+ const data = await response.json();
+ console.log("POST response data:", data); // Debugging statement
+ // Set status to Ready if successful
+ updateStatus('Ready');
+ } catch (error) {
+ console.error("POST request error:", error); // Debugging statement
+ // Set status to Error in case of failure
+ updateStatus('Error');
+ }
+ });
+ }
+
+ // Narrator Voice Dropdown Listener
+ const narratorVoiceSelect = document.getElementById('narrator_voice');
+ if (narratorVoiceSelect) {
+ narratorVoiceSelect.addEventListener('change', (event) => {
+ this.settings.narrator_voice_gen = `${event.target.value}.wav`;
+ this.onSettingsChangeAllTalk(); // Save the settings after change
+ });
+ }
+
+ const textNotInsideSelect = document.getElementById('at_narrator_text_not_inside');
+ if (textNotInsideSelect) {
+ textNotInsideSelect.addEventListener('change', (event) => {
+ this.settings.text_not_inside = event.target.value;
+ this.onSettingsChangeAllTalk(); // Save the settings after change
+ });
+ }
+
+ // AT Narrator Dropdown Listener
+ const atNarratorSelect = document.getElementById('at_narrator_enabled');
+ const ttsPassAsterisksCheckbox = document.getElementById('tts_pass_asterisks'); // Access the checkbox from index.js
+ const ttsNarrateQuotedCheckbox = document.getElementById('tts_narrate_quoted'); // Access the checkbox from index.js
+ const ttsNarrateDialoguesCheckbox = document.getElementById('tts_narrate_dialogues'); // Access the checkbox from index.js
+
+ if (atNarratorSelect && textNotInsideSelect && narratorVoiceSelect) {
+ atNarratorSelect.addEventListener('change', (event) => {
+ const isNarratorEnabled = event.target.value === 'true';
+ this.settings.narrator_enabled = isNarratorEnabled; // Update the setting here
+ textNotInsideSelect.disabled = !isNarratorEnabled;
+ narratorVoiceSelect.disabled = !isNarratorEnabled;
+
+ // Sync the state with the checkbox in index.js
+ if (ttsPassAsterisksCheckbox) {
+ ttsPassAsterisksCheckbox.checked = isNarratorEnabled;
+ }
+ // Uncheck and set tts_narrate_quoted to false if narrator is enabled
+ if (isNarratorEnabled) {
+ ttsNarrateQuotedCheckbox.checked = false;
+ ttsNarrateDialoguesCheckbox.checked = false;
+ // Trigger any necessary event handlers or updates for tts_narrate_quoted
+ $('#tts_narrate_quoted').trigger('change');
+ $('#tts_narrate_dialogues').trigger('change');
+ }
+
+ this.onSettingsChangeAllTalk(); // Save the settings after change
+ });
+ }
+
+
+ // Event Listener for AT Generation Method Dropdown
+ const atGenerationMethodSelect = document.getElementById('at_generation_method');
+ const atNarratorEnabledSelect = document.getElementById('at_narrator_enabled');
+ if (atGenerationMethodSelect) {
+ atGenerationMethodSelect.addEventListener('change', (event) => {
+ const selectedMethod = event.target.value;
+
+ if (selectedMethod === 'streaming_enabled') {
+ // Disable and unselect AT Narrator
+ atNarratorEnabledSelect.disabled = true;
+ atNarratorEnabledSelect.value = 'false';
+ textNotInsideSelect.disabled = true;
+ narratorVoiceSelect.disabled = true;
+ } else if (selectedMethod === 'standard_generation') {
+ // Enable AT Narrator
+ atNarratorEnabledSelect.disabled = false;
+ }
+ this.settings.at_generation_method = selectedMethod; // Update the setting here
+ this.onSettingsChangeAllTalk(); // Save the settings after change
+ });
+
+ }
+
+ // Listener for Language Dropdown
+ const languageSelect = document.getElementById('language_options');
+ if (languageSelect) {
+ languageSelect.addEventListener('change', (event) => {
+ this.settings.language = event.target.value;
+ this.onSettingsChangeAllTalk(); // Save the settings after change
+ });
+ }
+ }
+
+ //#############################//
+ // Store ST interface settings //
+ //#############################//
+
+ onSettingsChange() {
+ // Update settings that SillyTavern will save
+ this.settings.provider_endpoint = $('#at_server').val();
+ this.settings.language = $('#language_options').val();
+ saveTtsProviderSettings(); // This function should save settings handled by SillyTavern
+ // Call the function to handle AllTalk specific settings
+ this.onSettingsChangeAllTalk(); // Save the settings after change
+ }
+
+ onSettingsChangeAllTalk() {
+ // Update AllTalk specific settings and save to localStorage
+ this.settings.narrator_enabled = $('#at_narrator_enabled').val() === 'true';
+ this.settings.narrator_voice = $('#narrator_voice').val() + '.wav'; // assuming you need to append .wav
+ this.settings.text_not_inside = $('#at_narrator_text_not_inside').val(); // "character" or "narrator"
+ this.settings.at_generation_method = $('#at_generation_method').val(); // Streaming or standard
+ this.settings.language = $('#language_options').val(); // Streaming or standard
+
+ // Call the save function with the current settings
+ saveAllTalkSettings({
+ narrator_voice: this.settings.narrator_voice,
+ narrator_enabled: this.settings.narrator_enabled,
+ text_not_inside: this.settings.text_not_inside,
+ at_generation_method: this.settings.at_generation_method,
+ language: this.settings.language
+ });
+ }
+
+ //#########################//
+ // ST Handle Reload button //
+ //#########################//
+
+ async onRefreshClick() {
+ await this.checkReady(); // Check if the TTS provider is ready
+ await this.loadSettings(this.settings); // Reload the settings
+ // Additional actions as needed
+ }
+
+ //##################//
+ // Preview AT Voice //
+ //##################//
+
+ async previewTtsVoice(voiceName) {
+ try {
+ // Prepare data for POST request
+ const postData = new URLSearchParams();
+ postData.append("voice", `${voiceName}.wav`);
+ // Making the POST request
+ const response = await fetch(`${this.settings.provider_endpoint}/api/previewvoice/`, {
+ method: "POST",
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ },
+ body: postData,
+ });
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error(`[previewTtsVoice] Error Response Text:`, errorText);
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
+ }
+ // Assuming the server returns a URL to the .wav file
+ const data = await response.json();
+ if (data.output_file_url) {
+ // Use an audio element to play the .wav file
+ const audioElement = new Audio(data.output_file_url);
+ audioElement.play().catch(e => console.error("Error playing audio:", e));
+ } else {
+ console.warn("[previewTtsVoice] No output file URL received in the response");
+ throw new Error("No output file URL received in the response");
+ }
+
+ } catch (error) {
+ console.error("[previewTtsVoice] Exception caught during preview generation:", error);
+ throw error;
+ }
+ }
+
+ //#####################//
+ // Populate ST voices //
+ //#####################//
+
+ async getVoice(voiceName, generatePreview = false) {
+ // Ensure this.voices is populated
+ if (this.voices.length === 0) {
+ // Fetch voice objects logic
+ }
+ // Find the object where the name matches voiceName
+ const match = this.voices.find(voice => voice.name === voiceName);
+ if (!match) {
+ // Error handling
+ }
+ // Generate preview URL only if requested
+ if (!match.preview_url && generatePreview) {
+ // Generate preview logic
+ }
+ return match; // Return the found voice object
+ }
+
+ //##########################################//
+ // Generate TTS Streaming or call Standard //
+ //##########################################//
+
+ async generateTts(inputText, voiceId) {
+ try {
+ if (this.settings.at_generation_method === 'streaming_enabled') {
+ // For streaming method
+ const streamingUrl = `${this.settings.provider_endpoint}/api/tts-generate-streaming?text=${encodeURIComponent(inputText)}&voice=${encodeURIComponent(voiceId)}.wav&language=${encodeURIComponent(this.settings.language)}&output_file=stream_output.wav`;
+ const audioElement = new Audio(streamingUrl);
+ audioElement.play(); // Play the audio stream directly
+ console.log("Streaming URL:", streamingUrl);
+ return new Response(null, {
+ status: 200,
+ statusText: "OK",
+ headers: {
+ "Content-Type": "audio/wav",
+ "Content-Location": streamingUrl
+ }
+ });
+ } else {
+ // For standard method
+ const outputUrl = await this.fetchTtsGeneration(inputText, voiceId);
+ // Fetch the audio data as a blob from the URL
+ const audioResponse = await fetch(outputUrl);
+ if (!audioResponse.ok) {
+ throw new Error(`HTTP ${audioResponse.status}: Failed to fetch audio data`);
+ }
+ const audioBlob = await audioResponse.blob();
+ if (!audioBlob.type.startsWith('audio/')) {
+ throw new Error(`Invalid audio data format. Expecting audio/*, got ${audioBlob.type}`);
+ }
+ return new Response(audioBlob, {
+ status: 200,
+ statusText: "OK",
+ headers: {
+ "Content-Type": audioBlob.type
+ }
+ });
+ }
+ } catch (error) {
+ console.error("Error in generateTts:", error);
+ throw error;
+ }
+ }
+
+ //####################//
+ // Generate Standard //
+ //####################//
+
+ async fetchTtsGeneration(inputText, voiceId) {
+ // Prepare the request payload
+ const requestBody = new URLSearchParams({
+ 'text_input': inputText,
+ 'text_filtering': "standard",
+ 'character_voice_gen': voiceId + ".wav",
+ 'narrator_enabled': this.settings.narrator_enabled,
+ 'narrator_voice_gen': this.settings.narrator_voice_gen,
+ 'text_not_inside': this.settings.text_not_inside,
+ 'language': this.settings.language,
+ 'output_file_name': "st_output",
+ 'output_file_timestamp': "true",
+ 'autoplay': "false",
+ 'autoplay_volume': "0.8"
+ }).toString();
+
+ try {
+ const response = await doExtrasFetch(
+ `${this.settings.provider_endpoint}/api/tts-generate`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'Cache-Control': 'no-cache',
+ },
+ body: requestBody
+ }
+ );
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error(`[fetchTtsGeneration] Error Response Text:`, errorText);
+ // Uncomment the following line if you have a UI element for displaying errors
+ // toastr.error(response.statusText, 'TTS Generation Failed');
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
+ }
+ const data = await response.json();
+ const outputUrl = data.output_file_url;
+ return outputUrl; // Return only the output_file_url
+ } catch (error) {
+ console.error("[fetchTtsGeneration] Exception caught:", error);
+ throw error; // Rethrow the error for further handling
+ }
+ }
+}
+
+//#########################//
+// Update Status Messages //
+//#########################//
+
+function updateStatus(message) {
+ const statusElement = document.getElementById('status_info');
+ if (statusElement) {
+ statusElement.textContent = message;
+ switch (message) {
+ case 'Offline':
+ statusElement.style.color = 'red';
+ break;
+ case 'Ready':
+ statusElement.style.color = 'lightgreen';
+ break;
+ case 'Processing':
+ statusElement.style.color = 'blue';
+ break;
+ case 'Error':
+ statusElement.style.color = 'red';
+ break;
+ // Add more cases as needed
+ }
+ }
+}
+
+//########################//
+// Save/load AT Settings //
+//########################//
+
+function saveAllTalkSettings(settingsToSave) {
+ // Save the specific settings to localStorage
+ localStorage.setItem('AllTalkSettings', JSON.stringify(settingsToSave));
+}
+
+function loadAllTalkSettings() {
+ // Retrieve the settings from localStorage
+ const settings = localStorage.getItem('AllTalkSettings');
+ // If settings exist, parse them back into an object
+ if (settings) {
+ return JSON.parse(settings);
+ }
+ return null;
+}
diff --git a/templates/STfiles/index.js b/templates/STfiles/index.js
new file mode 100644
index 0000000..d07aea6
--- /dev/null
+++ b/templates/STfiles/index.js
@@ -0,0 +1,1028 @@
+import { callPopup, cancelTtsPlay, eventSource, event_types, name2, saveSettingsDebounced } from '../../../script.js';
+import { ModuleWorkerWrapper, doExtrasFetch, extension_settings, getApiUrl, getContext, modules } from '../../extensions.js';
+import { delay, escapeRegex, getStringHash } from '../../utils.js';
+import { EdgeTtsProvider } from './edge.js';
+import { ElevenLabsTtsProvider } from './elevenlabs.js';
+import { SileroTtsProvider } from './silerotts.js';
+import { CoquiTtsProvider } from './coqui.js';
+import { SystemTtsProvider } from './system.js';
+import { NovelTtsProvider } from './novel.js';
+import { power_user } from '../../power-user.js';
+import { registerSlashCommand } from '../../slash-commands.js';
+import { OpenAITtsProvider } from './openai.js';
+import { XTTSTtsProvider } from './xtts.js';
+import { AllTalkTtsProvider } from './alltalk.js';
+export { talkingAnimation };
+
+const UPDATE_INTERVAL = 1000;
+
+let voiceMapEntries = [];
+let voiceMap = {}; // {charName:voiceid, charName2:voiceid2}
+let storedvalue = false;
+let lastChatId = null;
+let lastMessageHash = null;
+
+const DEFAULT_VOICE_MARKER = '[Default Voice]';
+const DISABLED_VOICE_MARKER = 'disabled';
+
+export function getPreviewString(lang) {
+ const previewStrings = {
+ 'en-US': 'The quick brown fox jumps over the lazy dog',
+ 'en-GB': 'Sphinx of black quartz, judge my vow',
+ 'fr-FR': 'Portez ce vieux whisky au juge blond qui fume',
+ 'de-DE': 'Victor jagt zwölf Boxkämpfer quer über den großen Sylter Deich',
+ 'it-IT': 'Pranzo d\'acqua fa volti sghembi',
+ 'es-ES': 'Quiere la boca exhausta vid, kiwi, piña y fugaz jamón',
+ 'es-MX': 'Fabio me exige, sin tapujos, que añada cerveza al whisky',
+ 'ru-RU': 'В чащах юга жил бы цитрус? Да, но фальшивый экземпляр!',
+ 'pt-BR': 'Vejo xá gritando que fez show sem playback.',
+ 'pt-PR': 'Todo pajé vulgar faz boquinha sexy com kiwi.',
+ 'uk-UA': 'Фабрикуймо гідність, лящім їжею, ґав хапаймо, з\'єднавці чаш!',
+ 'pl-PL': 'Pchnąć w tę łódź jeża lub ośm skrzyń fig',
+ 'cs-CZ': 'Příliš žluťoučký kůň úpěl ďábelské ódy',
+ 'sk-SK': 'Vyhŕňme si rukávy a vyprážajme čínske ryžové cestoviny',
+ 'hu-HU': 'Árvíztűrő tükörfúrógép',
+ 'tr-TR': 'Pijamalı hasta yağız şoföre çabucak güvendi',
+ 'nl-NL': 'De waard heeft een kalfje en een pinkje opgegeten',
+ 'sv-SE': 'Yxskaftbud, ge vårbygd, zinkqvarn',
+ 'da-DK': 'Quizdeltagerne spiste jordbær med fløde, mens cirkusklovnen Walther spillede på xylofon',
+ 'ja-JP': 'いろはにほへと ちりぬるを わかよたれそ つねならむ うゐのおくやま けふこえて あさきゆめみし ゑひもせす',
+ 'ko-KR': '가나다라마바사아자차카타파하',
+ 'zh-CN': '我能吞下玻璃而不伤身体',
+ 'ro-RO': 'Muzicologă în bej vând whisky și tequila, preț fix',
+ 'bg-BG': 'Щъркелите се разпръснаха по цялото небе',
+ 'el-GR': 'Ταχίστη αλώπηξ βαφής ψημένη γη, δρασκελίζει υπέρ νωθρού κυνός',
+ 'fi-FI': 'Voi veljet, miksi juuri teille myin nämä vehkeet?',
+ 'he-IL': 'הקצינים צעקו: "כל הכבוד לצבא הצבאות!"',
+ 'id-ID': 'Jangkrik itu memang enak, apalagi kalau digoreng',
+ 'ms-MY': 'Muzik penyanyi wanita itu menggambarkan kehidupan yang penuh dengan duka nestapa',
+ 'th-TH': 'เป็นไงบ้างครับ ผมชอบกินข้าวผัดกระเพราหมูกรอบ',
+ 'vi-VN': 'Cô bé quàng khăn đỏ đang ngồi trên bãi cỏ xanh',
+ 'ar-SA': 'أَبْجَدِيَّة عَرَبِيَّة',
+ 'hi-IN': 'श्वेता ने श्वेता के श्वेते हाथों में श्वेता का श्वेता चावल पकड़ा',
+ };
+ const fallbackPreview = 'Neque porro quisquam est qui dolorem ipsum quia dolor sit amet';
+
+ return previewStrings[lang] ?? fallbackPreview;
+}
+
+let ttsProviders = {
+ ElevenLabs: ElevenLabsTtsProvider,
+ Silero: SileroTtsProvider,
+ XTTSv2: XTTSTtsProvider,
+ System: SystemTtsProvider,
+ Coqui: CoquiTtsProvider,
+ Edge: EdgeTtsProvider,
+ Novel: NovelTtsProvider,
+ OpenAI: OpenAITtsProvider,
+ AllTalk: AllTalkTtsProvider,
+};
+let ttsProvider;
+let ttsProviderName;
+
+let ttsLastMessage = null;
+
+async function onNarrateOneMessage() {
+ audioElement.src = '/sounds/silence.mp3';
+ const context = getContext();
+ const id = $(this).closest('.mes').attr('mesid');
+ const message = context.chat[id];
+
+ if (!message) {
+ return;
+ }
+
+ resetTtsPlayback();
+ ttsJobQueue.push(message);
+ moduleWorker();
+}
+
+async function onNarrateText(args, text) {
+ if (!text) {
+ return;
+ }
+
+ audioElement.src = '/sounds/silence.mp3';
+
+ // To load all characters in the voice map, set unrestricted to true
+ await initVoiceMap(true);
+
+ const baseName = args?.voice || name2;
+ const name = (baseName === 'SillyTavern System' ? DEFAULT_VOICE_MARKER : baseName) || DEFAULT_VOICE_MARKER;
+
+ const voiceMapEntry = voiceMap[name] === DEFAULT_VOICE_MARKER
+ ? voiceMap[DEFAULT_VOICE_MARKER]
+ : voiceMap[name];
+
+ if (!voiceMapEntry || voiceMapEntry === DISABLED_VOICE_MARKER) {
+ toastr.info(`Specified voice for ${name} was not found. Check the TTS extension settings.`);
+ return;
+ }
+
+ resetTtsPlayback();
+ ttsJobQueue.push({ mes: text, name: name });
+ await moduleWorker();
+
+ // Return back to the chat voices
+ await initVoiceMap(false);
+}
+
+async function moduleWorker() {
+ // Primarily determining when to add new chat to the TTS queue
+ const enabled = $('#tts_enabled').is(':checked');
+ $('body').toggleClass('tts', enabled);
+ if (!enabled) {
+ return;
+ }
+
+ const context = getContext();
+ const chat = context.chat;
+
+ processTtsQueue();
+ processAudioJobQueue();
+ updateUiAudioPlayState();
+
+ // Auto generation is disabled
+ if (extension_settings.tts.auto_generation == false) {
+ return;
+ }
+
+ // no characters or group selected
+ if (!context.groupId && context.characterId === undefined) {
+ return;
+ }
+
+ // Chat changed
+ if (
+ context.chatId !== lastChatId
+ ) {
+ currentMessageNumber = context.chat.length ? context.chat.length : 0;
+ saveLastValues();
+
+ // Force to speak on the first message in the new chat
+ if (context.chat.length === 1) {
+ lastMessageHash = -1;
+ }
+
+ return;
+ }
+
+ // take the count of messages
+ let lastMessageNumber = context.chat.length ? context.chat.length : 0;
+
+ // There's no new messages
+ let diff = lastMessageNumber - currentMessageNumber;
+ let hashNew = getStringHash((chat.length && chat[chat.length - 1].mes) ?? '');
+
+ // if messages got deleted, diff will be < 0
+ if (diff < 0) {
+ // necessary actions will be taken by the onChatDeleted() handler
+ return;
+ }
+
+ // if no new messages, or same message, or same message hash, do nothing
+ if (diff == 0 && hashNew === lastMessageHash) {
+ return;
+ }
+
+ // If streaming, wait for streaming to finish before processing new messages
+ if (context.streamingProcessor && !context.streamingProcessor.isFinished) {
+ return;
+ }
+
+ // clone message object, as things go haywire if message object is altered below (it's passed by reference)
+ const message = structuredClone(chat[chat.length - 1]);
+
+ // if last message within current message, message got extended. only send diff to TTS.
+ if (ttsLastMessage !== null && message.mes.indexOf(ttsLastMessage) !== -1) {
+ let tmp = message.mes;
+ message.mes = message.mes.replace(ttsLastMessage, '');
+ ttsLastMessage = tmp;
+ } else {
+ ttsLastMessage = message.mes;
+ }
+
+ // We're currently swiping. Don't generate voice
+ if (!message || message.mes === '...' || message.mes === '') {
+ return;
+ }
+
+ // Don't generate if message doesn't have a display text
+ if (extension_settings.tts.narrate_translated_only && !(message?.extra?.display_text)) {
+ return;
+ }
+
+ // Don't generate if message is a user message and user message narration is disabled
+ if (message.is_user && !extension_settings.tts.narrate_user) {
+ return;
+ }
+
+ // New messages, add new chat to history
+ lastMessageHash = hashNew;
+ currentMessageNumber = lastMessageNumber;
+
+ console.debug(
+ `Adding message from ${message.name} for TTS processing: "${message.mes}"`,
+ );
+ ttsJobQueue.push(message);
+}
+
+function talkingAnimation(switchValue) {
+ if (!modules.includes('talkinghead')) {
+ console.debug('Talking Animation module not loaded');
+ return;
+ }
+
+ const apiUrl = getApiUrl();
+ const animationType = switchValue ? 'start' : 'stop';
+
+ if (switchValue !== storedvalue) {
+ try {
+ console.log(animationType + ' Talking Animation');
+ doExtrasFetch(`${apiUrl}/api/talkinghead/${animationType}_talking`);
+ storedvalue = switchValue; // Update the storedvalue to the current switchValue
+ } catch (error) {
+ // Handle the error here or simply ignore it to prevent logging
+ }
+ }
+ updateUiAudioPlayState();
+}
+
+function resetTtsPlayback() {
+ // Stop system TTS utterance
+ cancelTtsPlay();
+
+ // Clear currently processing jobs
+ currentTtsJob = null;
+ currentAudioJob = null;
+
+ // Reset audio element
+ audioElement.currentTime = 0;
+ audioElement.src = '';
+
+ // Clear any queue items
+ ttsJobQueue.splice(0, ttsJobQueue.length);
+ audioJobQueue.splice(0, audioJobQueue.length);
+
+ // Set audio ready to process again
+ audioQueueProcessorReady = true;
+}
+
+function isTtsProcessing() {
+ let processing = false;
+
+ // Check job queues
+ if (ttsJobQueue.length > 0 || audioJobQueue.length > 0) {
+ processing = true;
+ }
+ // Check current jobs
+ if (currentTtsJob != null || currentAudioJob != null) {
+ processing = true;
+ }
+ return processing;
+}
+
+function debugTtsPlayback() {
+ console.log(JSON.stringify(
+ {
+ 'ttsProviderName': ttsProviderName,
+ 'voiceMap': voiceMap,
+ 'currentMessageNumber': currentMessageNumber,
+ 'audioPaused': audioPaused,
+ 'audioJobQueue': audioJobQueue,
+ 'currentAudioJob': currentAudioJob,
+ 'audioQueueProcessorReady': audioQueueProcessorReady,
+ 'ttsJobQueue': ttsJobQueue,
+ 'currentTtsJob': currentTtsJob,
+ 'ttsConfig': extension_settings.tts,
+ },
+ ));
+}
+window.debugTtsPlayback = debugTtsPlayback;
+
+//##################//
+// Audio Control //
+//##################//
+
+let audioElement = new Audio();
+audioElement.id = 'tts_audio';
+audioElement.autoplay = true;
+
+let audioJobQueue = [];
+let currentAudioJob;
+let audioPaused = false;
+let audioQueueProcessorReady = true;
+
+async function playAudioData(audioBlob) {
+ // Since current audio job can be cancelled, don't playback if it is null
+ if (currentAudioJob == null) {
+ console.log('Cancelled TTS playback because currentAudioJob was null');
+ }
+ const reader = new FileReader();
+ reader.onload = function (e) {
+ const srcUrl = e.target.result;
+ audioElement.src = srcUrl;
+ };
+ reader.readAsDataURL(audioBlob);
+ audioElement.addEventListener('ended', completeCurrentAudioJob);
+ audioElement.addEventListener('canplay', () => {
+ console.debug('Starting TTS playback');
+ audioElement.play();
+ });
+}
+
+window['tts_preview'] = function (id) {
+ const audio = document.getElementById(id);
+
+ if (audio && !$(audio).data('disabled')) {
+ audio.play();
+ }
+ else {
+ ttsProvider.previewTtsVoice(id);
+ }
+};
+
+async function onTtsVoicesClick() {
+ let popupText = '';
+
+ try {
+ const voiceIds = await ttsProvider.fetchTtsVoiceObjects();
+
+ for (const voice of voiceIds) {
+ popupText += `
+
+ ${voice.lang || ''}
+ ${voice.name}
+
+
`;
+ if (voice.preview_url) {
+ popupText += ``;
+ }
+ }
+ } catch {
+ popupText = 'Could not load voices list. Check your API key.';
+ }
+
+ callPopup(popupText, 'text');
+}
+
+function updateUiAudioPlayState() {
+ if (extension_settings.tts.enabled == true) {
+ $('#ttsExtensionMenuItem').show();
+ let img;
+ // Give user feedback that TTS is active by setting the stop icon if processing or playing
+ if (!audioElement.paused || isTtsProcessing()) {
+ img = 'fa-solid fa-stop-circle extensionsMenuExtensionButton';
+ } else {
+ img = 'fa-solid fa-circle-play extensionsMenuExtensionButton';
+ }
+ $('#tts_media_control').attr('class', img);
+ } else {
+ $('#ttsExtensionMenuItem').hide();
+ }
+}
+
+function onAudioControlClicked() {
+ audioElement.src = '/sounds/silence.mp3';
+ let context = getContext();
+ // Not pausing, doing a full stop to anything TTS is doing. Better UX as pause is not as useful
+ if (!audioElement.paused || isTtsProcessing()) {
+ resetTtsPlayback();
+ talkingAnimation(false);
+ } else {
+ // Default play behavior if not processing or playing is to play the last message.
+ ttsJobQueue.push(context.chat[context.chat.length - 1]);
+ }
+ updateUiAudioPlayState();
+}
+
+function addAudioControl() {
+
+ $('#extensionsMenu').prepend(`
+
+
+ TTS Playback
+ `);
+ $('#ttsExtensionMenuItem').attr('title', 'TTS play/pause').on('click', onAudioControlClicked);
+ updateUiAudioPlayState();
+}
+
+function completeCurrentAudioJob() {
+ audioQueueProcessorReady = true;
+ currentAudioJob = null;
+ talkingAnimation(false); //stop lip animation
+ // updateUiPlayState();
+}
+
+/**
+ * Accepts an HTTP response containing audio/mpeg data, and puts the data as a Blob() on the queue for playback
+ * @param {Response} response
+ */
+async function addAudioJob(response) {
+ const audioData = await response.blob();
+ if (!audioData.type.startsWith('audio/')) {
+ throw `TTS received HTTP response with invalid data format. Expecting audio/*, got ${audioData.type}`;
+ }
+ audioJobQueue.push(audioData);
+ console.debug('Pushed audio job to queue.');
+}
+
+async function processAudioJobQueue() {
+ // Nothing to do, audio not completed, or audio paused - stop processing.
+ if (audioJobQueue.length == 0 || !audioQueueProcessorReady || audioPaused) {
+ return;
+ }
+ try {
+ audioQueueProcessorReady = false;
+ currentAudioJob = audioJobQueue.pop();
+ playAudioData(currentAudioJob);
+ talkingAnimation(true);
+ } catch (error) {
+ console.error(error);
+ audioQueueProcessorReady = true;
+ }
+}
+
+//################//
+// TTS Control //
+//################//
+
+let ttsJobQueue = [];
+let currentTtsJob; // Null if nothing is currently being processed
+let currentMessageNumber = 0;
+
+function completeTtsJob() {
+ console.info(`Current TTS job for ${currentTtsJob?.name} completed.`);
+ currentTtsJob = null;
+}
+
+function saveLastValues() {
+ const context = getContext();
+ lastChatId = context.chatId;
+ lastMessageHash = getStringHash(
+ (context.chat.length && context.chat[context.chat.length - 1].mes) ?? '',
+ );
+}
+
+async function tts(text, voiceId, char) {
+ let response = await ttsProvider.generateTts(text, voiceId);
+
+ // RVC injection
+ if (extension_settings.rvc.enabled && typeof window['rvcVoiceConversion'] === 'function')
+ response = await window['rvcVoiceConversion'](response, char, text);
+
+ addAudioJob(response);
+ completeTtsJob();
+}
+
+async function processTtsQueue() {
+ // Called each moduleWorker iteration to pull chat messages from queue
+ if (currentTtsJob || ttsJobQueue.length <= 0 || audioPaused) {
+ return;
+ }
+
+ console.debug('New message found, running TTS');
+ currentTtsJob = ttsJobQueue.shift();
+ let text = extension_settings.tts.narrate_translated_only ? (currentTtsJob?.extra?.display_text || currentTtsJob.mes) : currentTtsJob.mes;
+
+ if (extension_settings.tts.skip_codeblocks) {
+ text = text.replace(/^\s{4}.*$/gm, '').trim();
+ text = text.replace(/```.*?```/gs, '').trim();
+ }
+
+ if (!extension_settings.tts.pass_asterisks) {
+ text = extension_settings.tts.narrate_dialogues_only
+ ? text.replace(/\*[^*]*?(\*|$)/g, '').trim() // remove asterisks content
+ : text.replaceAll('*', '').trim(); // remove just the asterisks
+ }
+
+ if (extension_settings.tts.narrate_quoted_only) {
+ const special_quotes = /[“”]/g; // Extend this regex to include other special quotes
+ text = text.replace(special_quotes, '"');
+ const matches = text.match(/".*?"/g); // Matches text inside double quotes, non-greedily
+ const partJoiner = (ttsProvider?.separator || ' ... ');
+ text = matches ? matches.join(partJoiner) : text;
+ }
+
+ if (typeof ttsProvider?.processText === 'function') {
+ text = await ttsProvider.processText(text);
+ }
+
+ // Collapse newlines and spaces into single space
+ text = text.replace(/\s+/g, ' ').trim();
+
+ console.log(`TTS: ${text}`);
+ const char = currentTtsJob.name;
+
+ // Remove character name from start of the line if power user setting is disabled
+ if (char && !power_user.allow_name2_display) {
+ const escapedChar = escapeRegex(char);
+ text = text.replace(new RegExp(`^${escapedChar}:`, 'gm'), '');
+ }
+
+ try {
+ if (!text) {
+ console.warn('Got empty text in TTS queue job.');
+ completeTtsJob();
+ return;
+ }
+
+ const voiceMapEntry = voiceMap[char] === DEFAULT_VOICE_MARKER ? voiceMap[DEFAULT_VOICE_MARKER] : voiceMap[char];
+
+ if (!voiceMapEntry || voiceMapEntry === DISABLED_VOICE_MARKER) {
+ throw `${char} not in voicemap. Configure character in extension settings voice map`;
+ }
+ const voice = await ttsProvider.getVoice(voiceMapEntry);
+ const voiceId = voice.voice_id;
+ if (voiceId == null) {
+ toastr.error(`Specified voice for ${char} was not found. Check the TTS extension settings.`);
+ throw `Unable to attain voiceId for ${char}`;
+ }
+ tts(text, voiceId, char);
+ } catch (error) {
+ console.error(error);
+ currentTtsJob = null;
+ }
+}
+
+// Secret function for now
+async function playFullConversation() {
+ const context = getContext();
+ const chat = context.chat;
+ ttsJobQueue = chat;
+}
+window.playFullConversation = playFullConversation;
+
+//#############################//
+// Extension UI and Settings //
+//#############################//
+
+function loadSettings() {
+ if (Object.keys(extension_settings.tts).length === 0) {
+ Object.assign(extension_settings.tts, defaultSettings);
+ }
+ for (const key in defaultSettings) {
+ if (!(key in extension_settings.tts)) {
+ extension_settings.tts[key] = defaultSettings[key];
+ }
+ }
+ $('#tts_provider').val(extension_settings.tts.currentProvider);
+ $('#tts_enabled').prop(
+ 'checked',
+ extension_settings.tts.enabled,
+ );
+ $('#tts_narrate_dialogues').prop('checked', extension_settings.tts.narrate_dialogues_only);
+ $('#tts_narrate_quoted').prop('checked', extension_settings.tts.narrate_quoted_only);
+ $('#tts_auto_generation').prop('checked', extension_settings.tts.auto_generation);
+ $('#tts_narrate_translated_only').prop('checked', extension_settings.tts.narrate_translated_only);
+ $('#tts_narrate_user').prop('checked', extension_settings.tts.narrate_user);
+ $('#tts_pass_asterisks').prop('checked', extension_settings.tts.pass_asterisks);
+ $('body').toggleClass('tts', extension_settings.tts.enabled);
+}
+
+const defaultSettings = {
+ voiceMap: '',
+ ttsEnabled: false,
+ currentProvider: 'ElevenLabs',
+ auto_generation: true,
+ narrate_user: false,
+};
+
+function setTtsStatus(status, success) {
+ $('#tts_status').text(status);
+ if (success) {
+ $('#tts_status').removeAttr('style');
+ } else {
+ $('#tts_status').css('color', 'red');
+ }
+}
+
+function onRefreshClick() {
+ Promise.all([
+ ttsProvider.onRefreshClick(),
+ // updateVoiceMap()
+ ]).then(() => {
+ extension_settings.tts[ttsProviderName] = ttsProvider.settings;
+ saveSettingsDebounced();
+ setTtsStatus('Successfully applied settings', true);
+ console.info(`Saved settings ${ttsProviderName} ${JSON.stringify(ttsProvider.settings)}`);
+ initVoiceMap();
+ updateVoiceMap();
+ }).catch(error => {
+ console.error(error);
+ setTtsStatus(error, false);
+ });
+}
+
+function onEnableClick() {
+ extension_settings.tts.enabled = $('#tts_enabled').is(
+ ':checked',
+ );
+ updateUiAudioPlayState();
+ saveSettingsDebounced();
+}
+
+
+function onAutoGenerationClick() {
+ extension_settings.tts.auto_generation = !!$('#tts_auto_generation').prop('checked');
+ saveSettingsDebounced();
+}
+
+
+function onNarrateDialoguesClick() {
+ extension_settings.tts.narrate_dialogues_only = !!$('#tts_narrate_dialogues').prop('checked');
+ saveSettingsDebounced();
+}
+
+function onNarrateUserClick() {
+ extension_settings.tts.narrate_user = !!$('#tts_narrate_user').prop('checked');
+ saveSettingsDebounced();
+}
+
+function onNarrateQuotedClick() {
+ extension_settings.tts.narrate_quoted_only = !!$('#tts_narrate_quoted').prop('checked');
+ saveSettingsDebounced();
+}
+
+
+function onNarrateTranslatedOnlyClick() {
+ extension_settings.tts.narrate_translated_only = !!$('#tts_narrate_translated_only').prop('checked');
+ saveSettingsDebounced();
+}
+
+function onSkipCodeblocksClick() {
+ extension_settings.tts.skip_codeblocks = !!$('#tts_skip_codeblocks').prop('checked');
+ saveSettingsDebounced();
+}
+
+function onPassAsterisksClick() {
+ extension_settings.tts.pass_asterisks = !!$('#tts_pass_asterisks').prop('checked');
+ saveSettingsDebounced();
+}
+
+//##############//
+// TTS Provider //
+//##############//
+
+async function loadTtsProvider(provider) {
+ //Clear the current config and add new config
+ $('#tts_provider_settings').html('');
+
+ if (!provider) {
+ return;
+ }
+
+ // Init provider references
+ extension_settings.tts.currentProvider = provider;
+ ttsProviderName = provider;
+ ttsProvider = new ttsProviders[provider];
+
+ // Init provider settings
+ $('#tts_provider_settings').append(ttsProvider.settingsHtml);
+ if (!(ttsProviderName in extension_settings.tts)) {
+ console.warn(`Provider ${ttsProviderName} not in Extension Settings, initiatilizing provider in settings`);
+ extension_settings.tts[ttsProviderName] = {};
+ }
+ await ttsProvider.loadSettings(extension_settings.tts[ttsProviderName]);
+ await initVoiceMap();
+}
+
+function onTtsProviderChange() {
+ const ttsProviderSelection = $('#tts_provider').val();
+ extension_settings.tts.currentProvider = ttsProviderSelection;
+ loadTtsProvider(ttsProviderSelection);
+}
+
+// Ensure that TTS provider settings are saved to extension settings.
+export function saveTtsProviderSettings() {
+ extension_settings.tts[ttsProviderName] = ttsProvider.settings;
+ updateVoiceMap();
+ saveSettingsDebounced();
+ console.info(`Saved settings ${ttsProviderName} ${JSON.stringify(ttsProvider.settings)}`);
+}
+
+
+//###################//
+// voiceMap Handling //
+//###################//
+
+async function onChatChanged() {
+ await resetTtsPlayback();
+ const voiceMapInit = initVoiceMap();
+ await Promise.race([voiceMapInit, delay(1000)]);
+ ttsLastMessage = null;
+}
+
+async function onChatDeleted() {
+ const context = getContext();
+
+ // update internal references to new last message
+ lastChatId = context.chatId;
+ currentMessageNumber = context.chat.length ? context.chat.length : 0;
+
+ // compare against lastMessageHash. If it's the same, we did not delete the last chat item, so no need to reset tts queue
+ let messageHash = getStringHash((context.chat.length && context.chat[context.chat.length - 1].mes) ?? '');
+ if (messageHash === lastMessageHash) {
+ return;
+ }
+ lastMessageHash = messageHash;
+ ttsLastMessage = (context.chat.length && context.chat[context.chat.length - 1].mes) ?? '';
+
+ // stop any tts playback since message might not exist anymore
+ await resetTtsPlayback();
+}
+
+/**
+ * Get characters in current chat
+ * @param {boolean} unrestricted - If true, will include all characters in voiceMapEntries, even if they are not in the current chat.
+ * @returns {string[]} - Array of character names
+ */
+function getCharacters(unrestricted) {
+ const context = getContext();
+
+ if (unrestricted) {
+ const names = context.characters.map(char => char.name);
+ names.unshift(DEFAULT_VOICE_MARKER);
+ return names;
+ }
+
+ let characters = [];
+ if (context.groupId === null) {
+ // Single char chat
+ characters.push(DEFAULT_VOICE_MARKER);
+ characters.push(context.name1);
+ characters.push(context.name2);
+ } else {
+ // Group chat
+ characters.push(DEFAULT_VOICE_MARKER);
+ characters.push(context.name1);
+ const group = context.groups.find(group => context.groupId == group.id);
+ for (let member of group.members) {
+ // Remove suffix
+ if (member.endsWith('.png')) {
+ member = member.slice(0, -4);
+ }
+ characters.push(member);
+ }
+ }
+ return characters;
+}
+
+function sanitizeId(input) {
+ // Remove any non-alphanumeric characters except underscore (_) and hyphen (-)
+ let sanitized = input.replace(/[^a-zA-Z0-9-_]/g, '');
+
+ // Ensure first character is always a letter
+ if (!/^[a-zA-Z]/.test(sanitized)) {
+ sanitized = 'element_' + sanitized;
+ }
+
+ return sanitized;
+}
+
+function parseVoiceMap(voiceMapString) {
+ let parsedVoiceMap = {};
+ for (const [charName, voiceId] of voiceMapString
+ .split(',')
+ .map(s => s.split(':'))) {
+ if (charName && voiceId) {
+ parsedVoiceMap[charName.trim()] = voiceId.trim();
+ }
+ }
+ return parsedVoiceMap;
+}
+
+
+
+/**
+ * Apply voiceMap based on current voiceMapEntries
+ */
+function updateVoiceMap() {
+ const tempVoiceMap = {};
+ for (const voice of voiceMapEntries) {
+ if (voice.voiceId === null) {
+ continue;
+ }
+ tempVoiceMap[voice.name] = voice.voiceId;
+ }
+ if (Object.keys(tempVoiceMap).length !== 0) {
+ voiceMap = tempVoiceMap;
+ console.log(`Voicemap updated to ${JSON.stringify(voiceMap)}`);
+ }
+ if (!extension_settings.tts[ttsProviderName].voiceMap) {
+ extension_settings.tts[ttsProviderName].voiceMap = {};
+ }
+ Object.assign(extension_settings.tts[ttsProviderName].voiceMap, voiceMap);
+ saveSettingsDebounced();
+}
+
+class VoiceMapEntry {
+ name;
+ voiceId;
+ selectElement;
+ constructor(name, voiceId = DEFAULT_VOICE_MARKER) {
+ this.name = name;
+ this.voiceId = voiceId;
+ this.selectElement = null;
+ }
+
+ addUI(voiceIds) {
+ let sanitizedName = sanitizeId(this.name);
+ let defaultOption = this.name === DEFAULT_VOICE_MARKER ?
+ `` :
+ ``;
+ let template = `
+
+ ${this.name}
+
+
+ `;
+ $('#tts_voicemap_block').append(template);
+
+ // Populate voice ID select list
+ for (const voiceId of voiceIds) {
+ const option = document.createElement('option');
+ option.innerText = voiceId.name;
+ option.value = voiceId.name;
+ $(`#tts_voicemap_char_${sanitizedName}_voice`).append(option);
+ }
+
+ this.selectElement = $(`#tts_voicemap_char_${sanitizedName}_voice`);
+ this.selectElement.on('change', args => this.onSelectChange(args));
+ this.selectElement.val(this.voiceId);
+ }
+
+ onSelectChange(args) {
+ this.voiceId = this.selectElement.find(':selected').val();
+ updateVoiceMap();
+ }
+}
+
+/**
+ * Init voiceMapEntries for character select list.
+ * @param {boolean} unrestricted - If true, will include all characters in voiceMapEntries, even if they are not in the current chat.
+ */
+export async function initVoiceMap(unrestricted = false) {
+ // Gate initialization if not enabled or TTS Provider not ready. Prevents error popups.
+ const enabled = $('#tts_enabled').is(':checked');
+ if (!enabled) {
+ return;
+ }
+
+ // Keep errors inside extension UI rather than toastr. Toastr errors for TTS are annoying.
+ try {
+ await ttsProvider.checkReady();
+ } catch (error) {
+ const message = `TTS Provider not ready. ${error}`;
+ setTtsStatus(message, false);
+ return;
+ }
+
+ setTtsStatus('TTS Provider Loaded', true);
+
+ // Clear existing voiceMap state
+ $('#tts_voicemap_block').empty();
+ voiceMapEntries = [];
+
+ // Get characters in current chat
+ const characters = getCharacters(unrestricted);
+
+ // Get saved voicemap from provider settings, handling new and old representations
+ let voiceMapFromSettings = {};
+ if ('voiceMap' in extension_settings.tts[ttsProviderName]) {
+ // Handle previous representation
+ if (typeof extension_settings.tts[ttsProviderName].voiceMap === 'string') {
+ voiceMapFromSettings = parseVoiceMap(extension_settings.tts[ttsProviderName].voiceMap);
+ // Handle new representation
+ } else if (typeof extension_settings.tts[ttsProviderName].voiceMap === 'object') {
+ voiceMapFromSettings = extension_settings.tts[ttsProviderName].voiceMap;
+ }
+ }
+
+ // Get voiceIds from provider
+ let voiceIdsFromProvider;
+ try {
+ voiceIdsFromProvider = await ttsProvider.fetchTtsVoiceObjects();
+ }
+ catch {
+ toastr.error('TTS Provider failed to return voice ids.');
+ }
+
+ // Build UI using VoiceMapEntry objects
+ for (const character of characters) {
+ if (character === 'SillyTavern System') {
+ continue;
+ }
+ // Check provider settings for voiceIds
+ let voiceId;
+ if (character in voiceMapFromSettings) {
+ voiceId = voiceMapFromSettings[character];
+ } else if (character === DEFAULT_VOICE_MARKER) {
+ voiceId = DISABLED_VOICE_MARKER;
+ } else {
+ voiceId = DEFAULT_VOICE_MARKER;
+ }
+ const voiceMapEntry = new VoiceMapEntry(character, voiceId);
+ voiceMapEntry.addUI(voiceIdsFromProvider);
+ voiceMapEntries.push(voiceMapEntry);
+ }
+ updateVoiceMap();
+}
+
+$(document).ready(function () {
+ function addExtensionControls() {
+ const settingsHtml = `
+
+
+
+ TTS
+
+
+
+
+
+ Select TTS Provider
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ $('#extensions_settings').append(settingsHtml);
+ $('#tts_refresh').on('click', onRefreshClick);
+ $('#tts_enabled').on('click', onEnableClick);
+ $('#tts_narrate_dialogues').on('click', onNarrateDialoguesClick);
+ $('#tts_narrate_quoted').on('click', onNarrateQuotedClick);
+ $('#tts_narrate_translated_only').on('click', onNarrateTranslatedOnlyClick);
+ $('#tts_skip_codeblocks').on('click', onSkipCodeblocksClick);
+ $('#tts_pass_asterisks').on('click', onPassAsterisksClick);
+ $('#tts_auto_generation').on('click', onAutoGenerationClick);
+ $('#tts_narrate_user').on('click', onNarrateUserClick);
+ $('#tts_voices').on('click', onTtsVoicesClick);
+ for (const provider in ttsProviders) {
+ $('#tts_provider').append($('').val(provider).text(provider));
+ }
+ $('#tts_provider').on('change', onTtsProviderChange);
+ $(document).on('click', '.mes_narrate', onNarrateOneMessage);
+ }
+ addExtensionControls(); // No init dependencies
+ loadSettings(); // Depends on Extension Controls and loadTtsProvider
+ loadTtsProvider(extension_settings.tts.currentProvider); // No dependencies
+ addAudioControl(); // Depends on Extension Controls
+ const wrapper = new ModuleWorkerWrapper(moduleWorker);
+ setInterval(wrapper.update.bind(wrapper), UPDATE_INTERVAL); // Init depends on all the things
+ eventSource.on(event_types.MESSAGE_SWIPED, resetTtsPlayback);
+ eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
+ eventSource.on(event_types.MESSAGE_DELETED, onChatDeleted);
+ eventSource.on(event_types.GROUP_UPDATED, onChatChanged);
+ registerSlashCommand('speak', onNarrateText, ['narrate', 'tts'], '(text) – narrate any text using currently selected character\'s voice. Use voice="Character Name" argument to set other voice from the voice map, example: /speak voice="Donald Duck" Quack!', true, true);
+ document.body.appendChild(audioElement);
+});