async loadSettings(settings) {
updateStatus('Offline');
- // Use default settings if no settings are provided
- this.settings = Object.keys(settings).length === 0 ? this.Settings : settings;
+ if (Object.keys(settings).length === 0) {
+ console.info('Using default AllTalk TTS Provider settings');
+ } else {
+ // Populate settings with provided values, ignoring server-provided settings
+ for (const key in settings) {
+ if (key in this.settings) {
+ this.settings[key] = settings[key];
+ } else {
+ console.debug(`Ignoring non-user-configurable setting: ${key}`);
+ }
+ }
+ }
+
+ // Update UI elements to reflect the loaded settings
+ $('#at_server').val(this.settings.provider_endpoint);
+ $('#language_options').val(this.settings.language);
+ //$('#voicemap').val(this.settings.voiceMap);
+ $('#at_generation_method').val(this.settings.at_generation_method);
+ $('#at_narrator_enabled').val(this.settings.narrator_enabled);
+ $('#at_narrator_text_not_inside').val(this.settings.at_narrator_text_not_inside);
+ $('#narrator_voice').val(this.settings.narrator_voice_gen);
+
+ console.debug('AllTalkTTS: Settings loaded');
try {
// Check if TTS provider is ready
await this.checkReady();
+ await this.updateSettingsFromServer(); // Fetch dynamic settings from the TTS server
await this.fetchTtsVoiceObjects(); // Fetch voices only if service is ready
- await this.updateSettingsFromServer();
this.updateNarratorVoicesDropdown();
this.updateLanguageDropdown();
this.setupEventListeners();
@@ -259,29 +208,23 @@ html += `
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');
+ this.settings.narrator_voice = this.settings.narrator_voice_gen;
// Apply settings to Narrator Voice dropdown
if (narratorVoiceSelect && this.settings.narrator_voice) {
narratorVoiceSelect.value = this.settings.narrator_voice.replace('.wav', '');
- this.settings.narrator_voice_gen = this.settings.narrator_voice;
- //console.log(this.settings.narrator_voice_gen)
}
// Apply settings to AT Narrator Enabled dropdown
if (atNarratorSelect) {
- //console.log(this.settings.narrator_enabled)
// Sync the state with the checkbox in index.js
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
@@ -314,18 +257,15 @@ html += `
const languageSelect = document.getElementById('language_options');
if (languageSelect && this.settings.language) {
languageSelect.value = this.settings.language;
- //console.log(this.settings.language)
}
// Apply settings to Text Not Inside dropdown
if (textNotInsideSelect && this.settings.text_not_inside) {
textNotInsideSelect.value = this.settings.text_not_inside;
this.settings.at_narrator_text_not_inside = this.settings.text_not_inside;
- //console.log(this.settings.at_narrator_text_not_inside)
}
// Apply settings to Generation Method dropdown
if (generationMethodSelect && this.settings.at_generation_method) {
generationMethodSelect.value = this.settings.at_generation_method;
- //console.log(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';
@@ -347,7 +287,6 @@ html += `
ftOption.textContent = 'XTTSv2 FT';
modelSelect.appendChild(ftOption);
}
- //console.log("Settings applied to HTML.");
}
//##############################//
@@ -486,7 +425,7 @@ html += `
const languageSelect = document.getElementById('language_options');
if (languageSelect) {
// Ensure default language is set
- this.settings.language = this.Settings.language;
+ this.settings.language = this.settings.language;
languageSelect.innerHTML = '';
for (let language in this.languageLabels) {
@@ -610,7 +549,7 @@ html += `
if (narratorVoiceSelect) {
narratorVoiceSelect.addEventListener('change', (event) => {
this.settings.narrator_voice_gen = `${event.target.value}.wav`;
- this.onSettingsChangeAllTalk(); // Save the settings after change
+ this.onSettingsChange(); // Save the settings after change
});
}
@@ -618,7 +557,7 @@ html += `
if (textNotInsideSelect) {
textNotInsideSelect.addEventListener('change', (event) => {
this.settings.text_not_inside = event.target.value;
- this.onSettingsChangeAllTalk(); // Save the settings after change
+ this.onSettingsChange(); // Save the settings after change
});
}
@@ -656,12 +595,11 @@ html += `
$('#tts_narrate_dialogues').click();
$('#tts_narrate_dialogues').trigger('change');
}
- this.onSettingsChangeAllTalk(); // Save the settings after change
+ this.onSettingsChange(); // 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');
@@ -680,7 +618,7 @@ html += `
atNarratorEnabledSelect.disabled = false;
}
this.settings.at_generation_method = selectedMethod; // Update the setting here
- this.onSettingsChangeAllTalk(); // Save the settings after change
+ this.onSettingsChange(); // Save the settings after change
});
}
@@ -689,9 +627,19 @@ html += `
if (languageSelect) {
languageSelect.addEventListener('change', (event) => {
this.settings.language = event.target.value;
- this.onSettingsChangeAllTalk(); // Save the settings after change
+ this.onSettingsChange(); // Save the settings after change
});
}
+
+ // Listener for AllTalk Endpoint Input
+ const atServerInput = document.getElementById('at_server');
+ if (atServerInput) {
+ atServerInput.addEventListener('input', (event) => {
+ this.settings.provider_endpoint = event.target.value;
+ this.onSettingsChange(); // Save the settings after change
+ });
+ }
+
}
//#############################//
@@ -699,30 +647,16 @@ html += `
//#############################//
onSettingsChange() {
- // Update settings that SillyTavern will save
- this.settings.provider_endpoint = $('#at_server').val();
+ // Update settings based on the UI elements
+ //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
- });
+ //this.settings.voiceMap = $('#voicemap').val();
+ this.settings.at_generation_method = $('#at_generation_method').val();
+ this.settings.narrator_enabled = $('#at_narrator_enabled').val();
+ this.settings.at_narrator_text_not_inside = $('#at_narrator_text_not_inside').val();
+ this.settings.narrator_voice_gen = $('#narrator_voice').val();
+ // Save the updated settings
+ saveTtsProviderSettings();
}
//#########################//
@@ -802,38 +736,20 @@ html += `
async generateTts(inputText, voiceId) {
try {
if (this.settings.at_generation_method === 'streaming_enabled') {
- // For streaming method
+ // Construct the streaming URL
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
- }
- });
+
+ // Return the streaming URL directly
+ return 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
- }
- });
+ return audioResponse; // Return the fetch response directly
}
} catch (error) {
console.error("Error in generateTts:", error);
@@ -841,6 +757,7 @@ html += `
}
}
+
//####################//
// Generate Standard //
//####################//
@@ -852,8 +769,8 @@ html += `
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}`);
}
@@ -912,27 +828,6 @@ function updateStatus(message) {
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
- console.log("settings", settingsToSave)
- 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;
-}
+}
\ No newline at end of file
diff --git a/public/scripts/extensions/tts/index.js b/public/scripts/extensions/tts/index.js
index 11f8ed4ec..dd84a22b5 100644
--- a/public/scripts/extensions/tts/index.js
+++ b/public/scripts/extensions/tts/index.js
@@ -1,1048 +1,1046 @@
-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, getBase64Async, getStringHash, onlyUnique } 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');
- }
- if (audioBlob instanceof Blob) {
- const srcUrl = await getBase64Async(audioBlob);
- audioElement.src = srcUrl;
- } else if (typeof audioBlob === 'string') {
- audioElement.src = audioBlob;
- } else {
- throw `TTS received invalid audio data type ${typeof 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) {
- if (typeof response === 'string') {
- audioJobQueue.push(response);
- } else {
- 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.shift();
- 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) {
- async function processResponse(response) {
- // RVC injection
- if (extension_settings.rvc.enabled && typeof window['rvcVoiceConversion'] === 'function')
- response = await window['rvcVoiceConversion'](response, char, text);
-
- await addAudioJob(response);
- }
-
- let response = await ttsProvider.generateTts(text, voiceId);
-
- // If async generator, process every chunk as it comes in
- if (typeof response[Symbol.asyncIterator] === 'function') {
- for await (const chunk of response) {
- await processResponse(chunk);
- }
- } else {
- await processResponse(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();
- console.log("setting narrate changed", extension_settings.tts.narrate_dialogues_only)
-}
-
-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();
- console.log("setting narrate quoted changed", extension_settings.tts.narrate_quoted_only)
-}
-
-
-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();
- console.log("setting pass asterisks", extension_settings.tts.pass_asterisks)
-}
-
-//##############//
-// 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.filter(onlyUnique);
- }
-
- 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) {
- const character = context.characters.find(char => char.avatar == member);
- if (character) {
- characters.push(character.name);
- }
- }
- }
- return characters.filter(onlyUnique);
-}
-
-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);
-});
+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, getBase64Async, getStringHash, onlyUnique } 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');
+ }
+ if (audioBlob instanceof Blob) {
+ const srcUrl = await getBase64Async(audioBlob);
+ audioElement.src = srcUrl;
+ } else if (typeof audioBlob === 'string') {
+ audioElement.src = audioBlob;
+ } else {
+ throw `TTS received invalid audio data type ${typeof 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) {
+ if (typeof response === 'string') {
+ audioJobQueue.push(response);
+ } else {
+ 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.shift();
+ 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) {
+ async function processResponse(response) {
+ // RVC injection
+ if (extension_settings.rvc.enabled && typeof window['rvcVoiceConversion'] === 'function')
+ response = await window['rvcVoiceConversion'](response, char, text);
+
+ await addAudioJob(response);
+ }
+
+ let response = await ttsProvider.generateTts(text, voiceId);
+
+ // If async generator, process every chunk as it comes in
+ if (typeof response[Symbol.asyncIterator] === 'function') {
+ for await (const chunk of response) {
+ await processResponse(chunk);
+ }
+ } else {
+ await processResponse(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();
+ console.log("setting pass asterisks", extension_settings.tts.pass_asterisks)
+}
+
+//##############//
+// 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.filter(onlyUnique);
+ }
+
+ 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) {
+ const character = context.characters.find(char => char.avatar == member);
+ if (character) {
+ characters.push(character.name);
+ }
+ }
+ }
+ return characters.filter(onlyUnique);
+}
+
+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);
+});
\ No newline at end of file
diff --git a/public/scripts/extensions/tts/style.css b/public/scripts/extensions/tts/style.css
index 0f4a2c70c..f92688715 100644
--- a/public/scripts/extensions/tts/style.css
+++ b/public/scripts/extensions/tts/style.css
@@ -88,3 +88,72 @@
gap: 5px;
margin-bottom: 5px;
}
+
+.at-settings-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+}
+
+.at-settings-option {
+ flex: 1;
+ margin: 0 10px;
+}
+
+.at-endpoint-option {
+ flex: 1;
+ margin: 0 10px;
+ margin-right: 25px;
+ width: 38%;
+}
+
+.at-website-row {
+ display: flex;
+ justify-content: start;
+ align-items: center;
+ margin-top: 10px;
+ margin-bottom: 10px;
+}
+
+.at-website-option {
+ flex: 1;
+ margin-right: 10px;
+ margin-left: 10px;
+}
+
+.at-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;
+}
+
+.at-status-message {
+ flex: 1;
+ margin: 0 10px;
+}
+
+.at-model-endpoint-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+}
+
+.at-model-option, .endpoint-option {
+ flex: 1;
+ margin: 0 10px;
+ margin-left: 10px;
+}
+
+.at-endpoint-option {
+ width: 38%;
+}
+
+#at-status_info {
+ color: lightgreen;
+}
\ No newline at end of file
From d19ba9a68cd7660d99e6bdab501a979a10b98b2a Mon Sep 17 00:00:00 2001
From: Cohee <18619528+Cohee1207@users.noreply.github.com>
Date: Thu, 18 Jan 2024 02:36:18 +0200
Subject: [PATCH 11/19] Fix format
---
public/scripts/extensions/tts/index.js | 2092 ++++++++++++------------
1 file changed, 1046 insertions(+), 1046 deletions(-)
diff --git a/public/scripts/extensions/tts/index.js b/public/scripts/extensions/tts/index.js
index dd84a22b5..8c72d7498 100644
--- a/public/scripts/extensions/tts/index.js
+++ b/public/scripts/extensions/tts/index.js
@@ -1,1046 +1,1046 @@
-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, getBase64Async, getStringHash, onlyUnique } 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');
- }
- if (audioBlob instanceof Blob) {
- const srcUrl = await getBase64Async(audioBlob);
- audioElement.src = srcUrl;
- } else if (typeof audioBlob === 'string') {
- audioElement.src = audioBlob;
- } else {
- throw `TTS received invalid audio data type ${typeof 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) {
- if (typeof response === 'string') {
- audioJobQueue.push(response);
- } else {
- 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.shift();
- 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) {
- async function processResponse(response) {
- // RVC injection
- if (extension_settings.rvc.enabled && typeof window['rvcVoiceConversion'] === 'function')
- response = await window['rvcVoiceConversion'](response, char, text);
-
- await addAudioJob(response);
- }
-
- let response = await ttsProvider.generateTts(text, voiceId);
-
- // If async generator, process every chunk as it comes in
- if (typeof response[Symbol.asyncIterator] === 'function') {
- for await (const chunk of response) {
- await processResponse(chunk);
- }
- } else {
- await processResponse(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();
- console.log("setting pass asterisks", extension_settings.tts.pass_asterisks)
-}
-
-//##############//
-// 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.filter(onlyUnique);
- }
-
- 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) {
- const character = context.characters.find(char => char.avatar == member);
- if (character) {
- characters.push(character.name);
- }
- }
- }
- return characters.filter(onlyUnique);
-}
-
-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);
-});
\ No newline at end of file
+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, getBase64Async, getStringHash, onlyUnique } 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');
+ }
+ if (audioBlob instanceof Blob) {
+ const srcUrl = await getBase64Async(audioBlob);
+ audioElement.src = srcUrl;
+ } else if (typeof audioBlob === 'string') {
+ audioElement.src = audioBlob;
+ } else {
+ throw `TTS received invalid audio data type ${typeof 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) {
+ if (typeof response === 'string') {
+ audioJobQueue.push(response);
+ } else {
+ 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.shift();
+ 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) {
+ async function processResponse(response) {
+ // RVC injection
+ if (extension_settings.rvc.enabled && typeof window['rvcVoiceConversion'] === 'function')
+ response = await window['rvcVoiceConversion'](response, char, text);
+
+ await addAudioJob(response);
+ }
+
+ let response = await ttsProvider.generateTts(text, voiceId);
+
+ // If async generator, process every chunk as it comes in
+ if (typeof response[Symbol.asyncIterator] === 'function') {
+ for await (const chunk of response) {
+ await processResponse(chunk);
+ }
+ } else {
+ await processResponse(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();
+ console.log("setting pass asterisks", extension_settings.tts.pass_asterisks)
+}
+
+//##############//
+// 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.filter(onlyUnique);
+ }
+
+ 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) {
+ const character = context.characters.find(char => char.avatar == member);
+ if (character) {
+ characters.push(character.name);
+ }
+ }
+ }
+ return characters.filter(onlyUnique);
+}
+
+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);
+});
From 7d34ed56e62453f7ab7749c7a3d5ab429362af2f Mon Sep 17 00:00:00 2001
From: Cohee <18619528+Cohee1207@users.noreply.github.com>
Date: Thu, 18 Jan 2024 16:33:02 +0200
Subject: [PATCH 12/19] #1712 Add authorization only if Extras key is set
---
public/scripts/extensions.js | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/public/scripts/extensions.js b/public/scripts/extensions.js
index 125140b6d..67fd1f1f0 100644
--- a/public/scripts/extensions.js
+++ b/public/scripts/extensions.js
@@ -167,9 +167,12 @@ async function doExtrasFetch(endpoint, args) {
if (!args.headers) {
args.headers = {};
}
- Object.assign(args.headers, {
- 'Authorization': `Bearer ${extension_settings.apiKey}`,
- });
+
+ if (extension_settings.apiKey) {
+ Object.assign(args.headers, {
+ 'Authorization': `Bearer ${extension_settings.apiKey}`,
+ });
+ }
const response = await fetch(endpoint, args);
return response;
From f966c398ef708c478af3120a411c047cb2d9791f Mon Sep 17 00:00:00 2001
From: Cohee <18619528+Cohee1207@users.noreply.github.com>
Date: Thu, 18 Jan 2024 16:36:26 +0200
Subject: [PATCH 13/19] Increase preset command timeouts
---
public/script.js | 2 +-
public/scripts/preset-manager.js | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/public/script.js b/public/script.js
index 571b95d2f..febc5da1c 100644
--- a/public/script.js
+++ b/public/script.js
@@ -7433,7 +7433,7 @@ async function connectAPISlash(_, text) {
toastr.info(`API set to ${text}, trying to connect..`);
try {
- await waitUntilCondition(() => online_status !== 'no_connection', 5000, 100);
+ await waitUntilCondition(() => online_status !== 'no_connection', 10000, 100);
console.log('Connection successful');
} catch {
console.log('Could not connect after 5 seconds, skipping.');
diff --git a/public/scripts/preset-manager.js b/public/scripts/preset-manager.js
index e1a1717b2..fd6ae3b9b 100644
--- a/public/scripts/preset-manager.js
+++ b/public/scripts/preset-manager.js
@@ -456,7 +456,7 @@ async function presetCommandCallback(_, name) {
*/
async function waitForConnection() {
try {
- await waitUntilCondition(() => online_status !== 'no_connection', 5000, 100);
+ await waitUntilCondition(() => online_status !== 'no_connection', 10000, 100);
} catch {
console.log('Timeout waiting for API to connect');
}
From b8445eb2cd29aede9d14a74a845cb36fae5d7e81 Mon Sep 17 00:00:00 2001
From: Cohee <18619528+Cohee1207@users.noreply.github.com>
Date: Thu, 18 Jan 2024 17:24:07 +0200
Subject: [PATCH 14/19] Add slash commands for instruct and context
---
public/script.js | 56 +++++++++++++++++++++++++++++++++
public/scripts/instruct-mode.js | 2 +-
2 files changed, 57 insertions(+), 1 deletion(-)
diff --git a/public/script.js b/public/script.js
index febc5da1c..867fff1ea 100644
--- a/public/script.js
+++ b/public/script.js
@@ -78,6 +78,7 @@ import {
ui_mode,
switchSimpleMode,
flushEphemeralStoppingStrings,
+ context_presets,
} from './scripts/power-user.js';
import {
@@ -178,6 +179,9 @@ import {
getInstructStoppingSequences,
autoSelectInstructPreset,
formatInstructModeSystemPrompt,
+ selectInstructPreset,
+ instruct_presets,
+ selectContextPreset,
} from './scripts/instruct-mode.js';
import { applyLocale, initLocales } from './scripts/i18n.js';
import { getFriendlyTokenizerName, getTokenCount, getTokenizerModel, initTokenizers, saveTokenCache } from './scripts/tokenizers.js';
@@ -7401,6 +7405,54 @@ const CONNECT_API_MAP = {
},
};
+async function selectContextCallback(_, name) {
+ if (!name) {
+ toastr.warning('Context preset name is required');
+ return '';
+ }
+
+ const contextNames = context_presets.map(preset => preset.name);
+ const fuse = new Fuse(contextNames);
+ const result = fuse.search(name);
+
+ if (result.length === 0) {
+ toastr.warning(`Context preset "${name}" not found`);
+ return '';
+ }
+
+ const foundName = result[0].item;
+ selectContextPreset(foundName);
+ return foundName;
+}
+
+async function selectInstructCallback(_, name) {
+ if (!name) {
+ toastr.warning('Instruct preset name is required');
+ return '';
+ }
+
+ const instructNames = instruct_presets.map(preset => preset.name);
+ const fuse = new Fuse(instructNames);
+ const result = fuse.search(name);
+
+ if (result.length === 0) {
+ toastr.warning(`Instruct preset "${name}" not found`);
+ return '';
+ }
+
+ const foundName = result[0].item;
+ selectInstructPreset(foundName);
+ return foundName;
+}
+
+async function enableInstructCallback() {
+ $('#instruct_enabled').prop('checked', true).trigger('change');
+}
+
+async function disableInstructCallback() {
+ $('#instruct_enabled').prop('checked', false).trigger('change');
+}
+
/**
* @param {string} text API name
*/
@@ -7698,6 +7750,10 @@ jQuery(async function () {
registerSlashCommand('closechat', doCloseChat, [], '– closes the current chat', true, true);
registerSlashCommand('panels', doTogglePanels, ['togglepanels'], '– toggle UI panels on/off', true, true);
registerSlashCommand('forcesave', doForceSave, [], '– forces a save of the current chat and settings', true, true);
+ registerSlashCommand('instruct', selectInstructCallback, [], '(name) – selects instruct mode preset by name', true, true);
+ registerSlashCommand('instruct-on', enableInstructCallback, [], '– enables instruct mode', true, true);
+ registerSlashCommand('instruct-off', disableInstructCallback, [], '– disables instruct mode', true, true);
+ registerSlashCommand('context', selectContextCallback, [], '(name) – selects context template by name', true, true);
setTimeout(function () {
$('#groupControlsToggle').trigger('click');
diff --git a/public/scripts/instruct-mode.js b/public/scripts/instruct-mode.js
index 05f7b8068..ed17866f8 100644
--- a/public/scripts/instruct-mode.js
+++ b/public/scripts/instruct-mode.js
@@ -83,7 +83,7 @@ function highlightDefaultPreset() {
* Select context template if not already selected.
* @param {string} preset Preset name.
*/
-function selectContextPreset(preset) {
+export function selectContextPreset(preset) {
// If context template is not already selected, select it
if (preset !== power_user.context.preset) {
$('#context_presets').val(preset).trigger('change');
From 4f55824d7f0e0ff90886e6f53850a74b839d0206 Mon Sep 17 00:00:00 2001
From: Cohee <18619528+Cohee1207@users.noreply.github.com>
Date: Thu, 18 Jan 2024 18:08:38 +0200
Subject: [PATCH 15/19] QR auto-execute on group member draft
---
public/script.js | 1 +
.../extensions/quick-reply/api/QuickReplyApi.js | 6 ++++++
.../scripts/extensions/quick-reply/html/qrEditor.html | 4 ++++
public/scripts/extensions/quick-reply/index.js | 6 ++++++
.../extensions/quick-reply/src/AutoExecuteHandler.js | 9 +++++++++
.../scripts/extensions/quick-reply/src/QuickReply.js | 10 +++++++++-
.../extensions/quick-reply/src/SlashCommandHandler.js | 3 +++
public/scripts/group-chats.js | 1 +
8 files changed, 39 insertions(+), 1 deletion(-)
diff --git a/public/script.js b/public/script.js
index 867fff1ea..b34c88d58 100644
--- a/public/script.js
+++ b/public/script.js
@@ -335,6 +335,7 @@ export const event_types = {
CHAT_DELETED: 'chat_deleted',
GROUP_CHAT_DELETED: 'group_chat_deleted',
GENERATE_BEFORE_COMBINE_PROMPTS: 'generate_before_combine_prompts',
+ GROUP_MEMBER_DRAFTED: 'group_member_drafted',
};
export const eventSource = new EventEmitter();
diff --git a/public/scripts/extensions/quick-reply/api/QuickReplyApi.js b/public/scripts/extensions/quick-reply/api/QuickReplyApi.js
index ddb12c29e..375c5b9ef 100644
--- a/public/scripts/extensions/quick-reply/api/QuickReplyApi.js
+++ b/public/scripts/extensions/quick-reply/api/QuickReplyApi.js
@@ -190,6 +190,7 @@ export class QuickReplyApi {
* @param {Boolean} [props.executeOnUser] whether to execute the quick reply after a user has sent a message
* @param {Boolean} [props.executeOnAi] whether to execute the quick reply after the AI has sent a message
* @param {Boolean} [props.executeOnChatChange] whether to execute the quick reply when a new chat is loaded
+ * @param {Boolean} [props.executeOnGroupMemberDraft] whether to execute the quick reply when a group member is selected
* @returns {QuickReply} the new quick reply
*/
createQuickReply(setName, label, {
@@ -200,6 +201,7 @@ export class QuickReplyApi {
executeOnUser,
executeOnAi,
executeOnChatChange,
+ executeOnGroupMemberDraft,
} = {}) {
const set = this.getSetByName(setName);
if (!set) {
@@ -214,6 +216,7 @@ export class QuickReplyApi {
qr.executeOnUser = executeOnUser ?? false;
qr.executeOnAi = executeOnAi ?? false;
qr.executeOnChatChange = executeOnChatChange ?? false;
+ qr.executeOnGroupMemberDraft = executeOnGroupMemberDraft ?? false;
qr.onUpdate();
return qr;
}
@@ -232,6 +235,7 @@ export class QuickReplyApi {
* @param {Boolean} [props.executeOnUser] whether to execute the quick reply after a user has sent a message
* @param {Boolean} [props.executeOnAi] whether to execute the quick reply after the AI has sent a message
* @param {Boolean} [props.executeOnChatChange] whether to execute the quick reply when a new chat is loaded
+ * @param {Boolean} [props.executeOnGroupMemberDraft] whether to execute the quick reply when a group member is selected
* @returns {QuickReply} the altered quick reply
*/
updateQuickReply(setName, label, {
@@ -243,6 +247,7 @@ export class QuickReplyApi {
executeOnUser,
executeOnAi,
executeOnChatChange,
+ executeOnGroupMemberDraft,
} = {}) {
const qr = this.getQrByLabel(setName, label);
if (!qr) {
@@ -256,6 +261,7 @@ export class QuickReplyApi {
qr.executeOnUser = executeOnUser ?? qr.executeOnUser;
qr.executeOnAi = executeOnAi ?? qr.executeOnAi;
qr.executeOnChatChange = executeOnChatChange ?? qr.executeOnChatChange;
+ qr.executeOnGroupMemberDraft = executeOnGroupMemberDraft ?? qr.executeOnGroupMemberDraft;
qr.onUpdate();
return qr;
}
diff --git a/public/scripts/extensions/quick-reply/html/qrEditor.html b/public/scripts/extensions/quick-reply/html/qrEditor.html
index a676381ac..d57ff565d 100644
--- a/public/scripts/extensions/quick-reply/html/qrEditor.html
+++ b/public/scripts/extensions/quick-reply/html/qrEditor.html
@@ -66,6 +66,10 @@
Execute on opening chat
+