From a428f67a2e54871ccbeee01d0f79c1c5a10601a9 Mon Sep 17 00:00:00 2001 From: erew123 <35898566+erew123@users.noreply.github.com> Date: Wed, 10 Jan 2024 14:09:31 +0000 Subject: [PATCH] SillyTavern Support files. Just kept here for safe keeping. --- templates/STfiles/alltalk.js | 888 +++++++++++++++++++++++++++++ templates/STfiles/index.js | 1028 ++++++++++++++++++++++++++++++++++ 2 files changed, 1916 insertions(+) create mode 100644 templates/STfiles/alltalk.js create mode 100644 templates/STfiles/index.js diff --git a/templates/STfiles/alltalk.js b/templates/STfiles/alltalk.js new file mode 100644 index 0000000..41dc251 --- /dev/null +++ b/templates/STfiles/alltalk.js @@ -0,0 +1,888 @@ +import { doExtrasFetch, getApiUrl, modules } from '../../extensions.js'; +import { saveTtsProviderSettings } from './index.js'; + +export { AllTalkTtsProvider }; + +const css = ` +.settings-row { + display: flex; + justify-content: space-between; /* Distribute space evenly between the children */ + align-items: center; + width: 100%; /* Full width to spread out the children */ +} + +.settings-option { + flex: 1; /* Each child will take equal space */ + margin: 0 10px; /* Adjust spacing as needed */ +} + +.endpoint-option { + flex: 1; /* Each child will take equal space */ + margin: 0 10px; /* Adjust spacing as needed */ + margin-right: 25px; + width: 38%; /* Full width to spread out the children */ +} + +.website-row { + display: flex; + justify-content: start; /* Aligns items to the start of the row */ + align-items: center; + margin-top: 10px; /* Top margin */ + margin-bottom: 10px; /* Bottom margin */ +} + +.website-option { + flex: 1; /* Allows each option to take equal space */ + margin-right: 10px; /* Spacing between the two options, adjust as needed */ + margin-left: 10px; /* Adjust this value to align as desired */ + /* Remove the individual margin top and bottom declarations if they are set globally on the .settings-row */ +} + +.settings-separator { + margin-top: 10px; + margin-bottom: 10px; + padding: 18x; + font-weight: bold; + border-top: 1px solid #e1e1e1; /* Grey line */ + border-bottom: 1px solid #e1e1e1; /* Grey line */ + text-align: center; +} + +.status-message { + flex: 1; /* Adjust as needed */ + margin: 0 10px; + /* Additional styling */ +} + +.model-endpoint-row { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.model-option, .endpoint-option { + flex: 1; + margin: 0 10px; + margin-left: 10px; /* Adjust this value to align as desired */ + /* Adjust width as needed to fit in one line */ +} + +.endpoint-option { + /* Adjust width to align with model dropdown */ + width: 38%; +} + +#status_info { + color: lightgreen; /* Or any specific shade of green you prefer */ +} +`; + +const style = document.createElement('style'); +style.type = 'text/css'; +style.appendChild(document.createTextNode(css)); +document.head.appendChild(style); + +class AllTalkTtsProvider { + //########// + // Config // + //########// + + settings; + ready = false; + voices = []; + separator = '. '; + audioElement = document.createElement('audio'); + + languageLabels = { + 'Arabic': 'ar', + 'Brazilian Portuguese': 'pt', + 'Chinese': 'zh-cn', + 'Czech': 'cs', + 'Dutch': 'nl', + 'English': 'en', + 'French': 'fr', + 'German': 'de', + 'Italian': 'it', + 'Polish': 'pl', + 'Russian': 'ru', + 'Spanish': 'es', + 'Turkish': 'tr', + 'Japanese': 'ja', + 'Korean': 'ko', + 'Hungarian': 'hu', + 'Hindi': 'hi', + }; + + Settings = { + provider_endpoint: 'http://localhost:7851', + language: 'en', + voiceMap: {}, + at_generation_method: 'standard_generation', + narrator_enabled: 'false', + at_narrator_text_not_inside: 'narrator', + narrator_voice_gen: 'female_01.wav', + finetuned_model: 'false' + }; + + get settingsHtml() { + let html = `
AllTalk Settings
`; + + html += `
+ +
+ + +
+
`; + + html += `
+ +
+ + +
+ +
+ + +
+ +
`; + + html += `
+
+ + +
+
+ + +
+
`; + + + html += `
+
+ + +
+ +
+ + +
+
`; + + + html += `
+
+ + +
+
+ + +
+
+ Status: Ready +
+
+ +
+
`; + + + html += `
+
+ AllTalk Config & Docs. +
+ +
+ AllTalk Website. +
+
`; + + 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($('