Blame Raw
Cohee · 51ad27fb · · 1622 lines (58.0 KB)
5 contributors
1import { cancelTtsPlay, eventSource, event_types, getCurrentChatId, isStreamingEnabled, name2, saveSettingsDebounced, substituteParams } from '../../../script.js';
2import { ModuleWorkerWrapper, extension_settings, getContext, renderExtensionTemplateAsync } from '../../extensions.js';
3import { delay, escapeRegex, getBase64Async, getStringHash, onlyUnique, regexFromString } from '../../utils.js';
4import { accountStorage } from '../../util/AccountStorage.js';
5import { EdgeTtsProvider } from './edge.js';
6import { ElevenLabsTtsProvider } from './elevenlabs.js';
7import { SileroTtsProvider } from './silerotts.js';
8import { GptSovitsV2Provider } from './gpt-sovits-v2.js';
9import { GptSoVITSAdapterProvider } from './gpt-sovits-adapter.js';
10import { CoquiTtsProvider } from './coqui.js';
11import { SystemTtsProvider } from './system.js';
12import { NovelTtsProvider } from './novel.js';
13import { power_user } from '../../power-user.js';
14import { OpenAITtsProvider } from './openai.js';
15import { OpenAICompatibleTtsProvider } from './openai-compatible.js';
16import { XTTSTtsProvider } from './xtts.js';
17import { VITSTtsProvider } from './vits.js';
18import { GSVITtsProvider } from './gsvi.js';
19import { SBVits2TtsProvider } from './sbvits2.js';
20import { AllTalkTtsProvider } from './alltalk.js';
21import { CosyVoiceProvider } from './cosyvoice.js';
22import { SpeechT5TtsProvider } from './speecht5.js';
23import { AzureTtsProvider } from './azure.js';
24import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
25import { SlashCommand } from '../../slash-commands/SlashCommand.js';
26import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
27import { debounce_timeout } from '../../constants.js';
28import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js';
29import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
30import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
31import { GoogleTranslateTtsProvider } from './google-translate.js';
32import { GoogleNativeTtsProvider } from './google-native.js';
33import { ChatterboxTtsProvider } from './chatterbox.js';
34import { KokoroTtsProvider } from './kokoro.js';
35import { TtsWebuiProvider } from './tts-webui.js';
36import { PollinationsTtsProvider } from './pollinations.js';
37import { MiniMaxTtsProvider } from './minimax.js';
38import { ElectronHubTtsProvider } from './electronhub.js';
39import { ChutesTtsProvider } from './chutes.js';
40import { VolcengineTtsProvider } from './volcengine.js';
41import { applyLocale, t } from '/scripts/i18n.js';
42
43const UPDATE_INTERVAL = 1000;
44const wrapper = new ModuleWorkerWrapper(moduleWorker);
45
46let voiceMapEntries = [];
47let voiceMap = {}; // {charName:voiceid, charName2:voiceid2}
48let lastChatId = null;
49let lastMessage = null;
50let lastMessageHash = null;
51let periodicMessageGenerationTimer = null;
52let lastPositionOfParagraphEnd = -1;
53let currentInitVoiceMapPromise = null;
54
55const DEFAULT_VOICE_MARKER = '[Default Voice]';
56const DISABLED_VOICE_MARKER = 'disabled';
57
58export function getPreviewString(lang) {
59 const previewStrings = {
60 'en-US': 'The quick brown fox jumps over the lazy dog',
61 'en-GB': 'Sphinx of black quartz, judge my vow',
62 'fr-FR': 'Portez ce vieux whisky au juge blond qui fume',
63 'de-DE': 'Victor jagt zwölf Boxkämpfer quer über den großen Sylter Deich',
64 'it-IT': 'Pranzo d\'acqua fa volti sghembi',
65 'es-ES': 'Quiere la boca exhausta vid, kiwi, piña y fugaz jamón',
66 'es-MX': 'Fabio me exige, sin tapujos, que añada cerveza al whisky',
67 'ru-RU': 'В чащах юга жил бы цитрус? Да, но фальшивый экземпляр!',
68 'pt-BR': 'Vejo xá gritando que fez show sem playback.',
69 'pt-PR': 'Todo pajé vulgar faz boquinha sexy com kiwi.',
70 'uk-UA': 'Фабрикуймо гідність, лящім їжею, ґав хапаймо, з\'єднавці чаш!',
71 'pl-PL': 'Pchnąć w tę łódź jeża lub ośm skrzyń fig',
72 'cs-CZ': 'Příliš žluťoučký kůň úpěl ďábelské ódy',
73 'sk-SK': 'Vyhŕňme si rukávy a vyprážajme čínske ryžové cestoviny',
74 'hu-HU': 'Árvíztűrő tükörfúrógép',
75 'tr-TR': 'Pijamalı hasta yağız şoföre çabucak güvendi',
76 'nl-NL': 'De waard heeft een kalfje en een pinkje opgegeten',
77 'sv-SE': 'Yxskaftbud, ge vårbygd, zinkqvarn',
78 'da-DK': 'Quizdeltagerne spiste jordbær med fløde, mens cirkusklovnen Walther spillede på xylofon',
79 'ja-JP': 'いろはにほへと ちりぬるを わかよたれそ つねならむ うゐのおくやま けふこえて あさきゆめみし ゑひもせす',
80 'ko-KR': '가나다라마바사아자차카타파하',
81 'zh-CN': '我能吞下玻璃而不伤身体',
82 'ro-RO': 'Muzicologă în bej vând whisky și tequila, preț fix',
83 'bg-BG': 'Щъркелите се разпръснаха по цялото небе',
84 'el-GR': 'Ταχίστη αλώπηξ βαφής ψημένη γη, δρασκελίζει υπέρ νωθρού κυνός',
85 'fi-FI': 'Voi veljet, miksi juuri teille myin nämä vehkeet?',
86 'he-IL': 'הקצינים צעקו: "כל הכבוד לצבא הצבאות!"',
87 'id-ID': 'Jangkrik itu memang enak, apalagi kalau digoreng',
88 'ms-MY': 'Muzik penyanyi wanita itu menggambarkan kehidupan yang penuh dengan duka nestapa',
89 'th-TH': 'เป็นไงบ้างครับ ผมชอบกินข้าวผัดกระเพราหมูกรอบ',
90 'vi-VN': 'Cô bé quàng khăn đỏ đang ngồi trên bãi cỏ xanh',
91 'ar-SA': 'أَبْجَدِيَّة عَرَبِيَّة',
92 'hi-IN': 'श्वेता ने श्वेता के श्वेते हाथों में श्वेता का श्वेता चावल पकड़ा',
93 };
94 const fallbackPreview = 'Neque porro quisquam est qui dolorem ipsum quia dolor sit amet';
95
96 return previewStrings[lang] ?? fallbackPreview;
97}
98
99/**
100 * Registers a TTS provider.
101 * @param {string} name Name of the TTS provider to register.
102 * @param {function} provider Provider class.
103 */
104export function registerTtsProvider(name, provider) {
105 if (!name || typeof name !== 'string') {
106 throw new Error(`TTS provider name ${name} is not a valid string.`);
107 }
108 if (!provider || typeof provider !== 'function') {
109 throw new Error(`TTS provider ${name} is not a valid provider class.`);
110 }
111 if (ttsProviders[name]) {
112 throw new Error(`TTS provider ${name} is already registered.`);
113 }
114 ttsProviders[name] = provider;
115 console.info(`Registered TTS provider: ${name}`);
116 $('#tts_provider').append($('<option />').val(name).text(name));
117
118 // Load if it was previously selected
119 if (extension_settings.tts.currentProvider === name) {
120 loadTtsProvider(name);
121 }
122}
123
124const ttsProviders = {
125 AllTalk: AllTalkTtsProvider,
126 Azure: AzureTtsProvider,
127 Chatterbox: ChatterboxTtsProvider,
128 Chutes: ChutesTtsProvider,
129 Coqui: CoquiTtsProvider,
130 'CosyVoice (Unofficial)': CosyVoiceProvider,
131 Edge: EdgeTtsProvider,
132 ElevenLabs: ElevenLabsTtsProvider,
133 'Electron Hub': ElectronHubTtsProvider,
134 'Google Translate': GoogleTranslateTtsProvider,
135 'Google Gemini TTS': GoogleNativeTtsProvider,
136 GSVI: GSVITtsProvider,
137 'GPT-SoVITS-Adapter': GptSoVITSAdapterProvider,
138 'GPT-SoVITS-V2 (Unofficial)': GptSovitsV2Provider,
139 Kokoro: KokoroTtsProvider,
140 MiniMax: MiniMaxTtsProvider,
141 Novel: NovelTtsProvider,
142 OpenAI: OpenAITtsProvider,
143 'OpenAI Compatible': OpenAICompatibleTtsProvider,
144 Pollinations: PollinationsTtsProvider,
145 SBVits2: SBVits2TtsProvider,
146 Silero: SileroTtsProvider,
147 SpeechT5: SpeechT5TtsProvider,
148 System: SystemTtsProvider,
149 'TTS WebUI': TtsWebuiProvider,
150 VITS: VITSTtsProvider,
151 XTTSv2: XTTSTtsProvider,
152 Volcengine: VolcengineTtsProvider,
153};
154let ttsProvider;
155let ttsProviderName;
156
157
158async function onNarrateOneMessage() {
159 audioElement.src = '/sounds/silence.mp3';
160 const context = getContext();
161 const id = $(this).closest('.mes').attr('mesid');
162 const message = context.chat[id];
163
164 if (!message) {
165 return;
166 }
167
168 resetTtsPlayback();
169 processAndQueueTtsMessage(message, Number(id), { manual: true });
170 moduleWorker();
171}
172
173async function onNarrateText(args, text) {
174 if (!text) {
175 return '';
176 }
177
178 audioElement.src = '/sounds/silence.mp3';
179
180 // To load all characters in the voice map, set unrestricted to true
181 await initVoiceMap(true);
182
183 const baseName = args?.voice || name2;
184 const name = (baseName === 'SillyTavern System' ? DEFAULT_VOICE_MARKER : baseName) || DEFAULT_VOICE_MARKER;
185
186 const voiceMapEntry = voiceMap[name] === DEFAULT_VOICE_MARKER
187 ? voiceMap[DEFAULT_VOICE_MARKER]
188 : voiceMap[name];
189
190 if (voiceMapEntry === DISABLED_VOICE_MARKER) {
191 toastr.info(`TTS voice for ${name} is disabled.`);
192 await initVoiceMap(false);
193 return;
194 }
195
196 if (!voiceMapEntry) {
197 toastr.info(`Specified voice for ${name} was not found. Check the TTS extension settings.`);
198 await initVoiceMap(false);
199 return;
200 }
201
202 resetTtsPlayback();
203 processAndQueueTtsMessage({ mes: text, name: name }, null, { manual: true });
204 await moduleWorker();
205
206 // Return back to the chat voices
207 await initVoiceMap(false);
208 return '';
209}
210
211async function moduleWorker() {
212 if (!extension_settings.tts.enabled) {
213 return;
214 }
215
216 processTtsQueue();
217 processAudioJobQueue();
218 updateUiAudioPlayState();
219}
220
221function resetTtsPlayback() {
222 // Stop system TTS utterance
223 cancelTtsPlay();
224
225 // Clear currently processing jobs
226 currentTtsJob = null;
227 currentAudioJob = null;
228
229 // Reset audio element
230 audioElement.currentTime = 0;
231 audioElement.src = '';
232
233 // Clear any queue items
234 ttsJobQueue.splice(0, ttsJobQueue.length);
235 audioJobQueue.splice(0, audioJobQueue.length);
236
237 // Set audio ready to process again
238 audioQueueProcessorReady = true;
239}
240
241function isTtsProcessing() {
242 let processing = false;
243
244 // Check job queues
245 if (ttsJobQueue.length > 0 || audioJobQueue.length > 0) {
246 processing = true;
247 }
248 // Check current jobs
249 if (currentTtsJob != null || currentAudioJob != null) {
250 processing = true;
251 }
252 return processing;
253}
254
255/**
256 * @typedef {ChatMessage & { id?: number, manual?: boolean, segmentText?: string, segmentType?: string }} TtsMessage
257 */
258
259/**
260 * Clones a message, attaches the given message ID, then splits by paragraphs
261 * (if enabled) and adds each part to the TTS job queue.
262 * @param {ChatMessage} message - The message object to be processed.
263 * @param {number|null} [messageId=null] - The chat message index to associate with TTS events.
264 * @param {object} [options={}] - Additional options for processing.
265 * @param {boolean} [options.manual=false] - Whether this TTS job was manually triggered (e.g., from the UI) rather than automatically from a new chat message.
266 * @returns {void}
267 */
268function processAndQueueTtsMessage(message, messageId = null, { manual = false } = {}) {
269 /** @type {TtsMessage} */
270 const clone = structuredClone(message);
271 clone.id = messageId ?? null;
272 clone.manual = manual ?? false;
273
274 if (!extension_settings.tts.narrate_by_paragraphs) {
275 ttsJobQueue.push(clone);
276 return;
277 }
278
279 const lines = clone.mes.split('\n');
280
281 for (let i = 0; i < lines.length; i++) {
282 const line = lines[i];
283
284 if (line.length === 0) {
285 continue;
286 }
287
288 ttsJobQueue.push(
289 Object.assign({}, clone, {
290 mes: line,
291 }),
292 );
293 }
294}
295
296function debugTtsPlayback() {
297 console.log(JSON.stringify(
298 {
299 'ttsProviderName': ttsProviderName,
300 'voiceMap': voiceMap,
301 'audioPaused': audioPaused,
302 'audioJobQueue': audioJobQueue,
303 'currentAudioJob': currentAudioJob,
304 'audioQueueProcessorReady': audioQueueProcessorReady,
305 'ttsJobQueue': ttsJobQueue,
306 'currentTtsJob': currentTtsJob,
307 'ttsConfig': extension_settings.tts,
308 },
309 ));
310}
311globalThis.debugTtsPlayback = debugTtsPlayback;
312
313//##################//
314// Audio Control //
315//##################//
316
317let audioElement = new Audio();
318audioElement.id = 'tts_audio';
319audioElement.autoplay = true;
320
321/**
322 * @type AudioJob[] Audio job queue
323 * @typedef {{audioBlob: Blob | string, char: string}} AudioJob Audio job object
324 */
325const audioJobQueue = [];
326/**
327 * @type AudioJob Current audio job
328 */
329let currentAudioJob;
330let audioPaused = false;
331let audioQueueProcessorReady = true;
332
333/**
334 * Play audio data from audio job object.
335 * @param {AudioJob} audioJob Audio job object
336 * @returns {Promise<void>} Promise that resolves when audio playback is started
337 */
338async function playAudioData(audioJob) {
339 const { audioBlob, char } = audioJob;
340 // Since current audio job can be cancelled, don't playback if it is null
341 if (currentAudioJob == null) {
342 console.log('Cancelled TTS playback because currentAudioJob was null');
343 }
344 if (audioBlob instanceof Blob) {
345 const srcUrl = await getBase64Async(audioBlob);
346
347 // VRM lip sync
348 if (extension_settings.vrm?.enabled && typeof globalThis.vrmLipSync === 'function') {
349 await globalThis.vrmLipSync(audioBlob, char);
350 }
351
352 audioElement.src = srcUrl;
353 } else if (typeof audioBlob === 'string') {
354 audioElement.src = audioBlob;
355 } else {
356 throw `TTS received invalid audio data type ${typeof audioBlob}`;
357 }
358 audioElement.addEventListener('ended', completeCurrentAudioJob);
359 audioElement.addEventListener('canplay', () => {
360 console.debug('Starting TTS playback');
361 audioElement.playbackRate = extension_settings.tts.playback_rate;
362 audioElement.play();
363 });
364}
365
366globalThis.tts_preview = function (id) {
367 const audio = document.getElementById(id);
368
369 if (audio instanceof HTMLAudioElement && !$(audio).data('disabled')) {
370 audio.play();
371 } else {
372 ttsProvider.previewTtsVoice(id);
373 }
374};
375
376async function onTtsVoicesClick() {
377 let popupText = '';
378
379 try {
380 const voiceIds = await ttsProvider.fetchTtsVoiceObjects();
381
382 for (const voice of voiceIds) {
383 popupText += `
384 <div class="voice_preview">
385 <span class="voice_lang">${voice.lang || ''}</span>
386 <b class="voice_name">${voice.name}</b>
387 <i onclick="tts_preview('${voice.voice_id}')" class="fa-solid fa-play"></i>
388 </div>`;
389 if (voice.preview_url) {
390 popupText += `<audio id="${voice.voice_id}" src="${voice.preview_url}" data-disabled="${voice.preview_url == false}"></audio>`;
391 }
392 }
393 } catch {
394 popupText = 'Could not load voices list. Check your API key.';
395 }
396
397 callGenericPopup(popupText, POPUP_TYPE.TEXT, '', { allowVerticalScrolling: true });
398}
399
400function updateUiAudioPlayState() {
401 if (extension_settings.tts.enabled == true) {
402 $('#ttsExtensionMenuItem').show();
403 let img;
404 // Give user feedback that TTS is active by setting the stop icon if processing or playing
405 if (!audioElement.paused || isTtsProcessing()) {
406 img = 'fa-solid fa-stop-circle extensionsMenuExtensionButton';
407 } else {
408 img = 'fa-solid fa-circle-play extensionsMenuExtensionButton';
409 }
410 $('#tts_media_control').attr('class', img);
411 } else {
412 $('#ttsExtensionMenuItem').hide();
413 }
414}
415
416function onAudioControlClicked() {
417 audioElement.src = '/sounds/silence.mp3';
418 let context = getContext();
419 // Not pausing, doing a full stop to anything TTS is doing. Better UX as pause is not as useful
420 if (!audioElement.paused || isTtsProcessing()) {
421 resetTtsPlayback();
422 } else if (context?.chat?.length > 0) {
423 // Default play behavior if not processing or playing is to play the last message.
424 const id = context.chat.length - 1;
425 processAndQueueTtsMessage(context.chat[id], id, { manual: true });
426 }
427 updateUiAudioPlayState();
428}
429
430function addAudioControl() {
431 $('#tts_wand_container').append(applyLocale(`
432 <div id="ttsExtensionMenuItem" class="list-group-item flex-container flexGap5">
433 <div id="tts_media_control" class="extensionsMenuExtensionButton "/></div>
434 <span data-i18n="TTS Playback">TTS Playback</span>
435 </div>`));
436 $('#tts_wand_container').append(applyLocale(`
437 <div id="ttsExtensionNarrateAll" class="list-group-item flex-container flexGap5">
438 <div class="extensionsMenuExtensionButton fa-solid fa-radio"></div>
439 <span data-i18n="Narrate All Chat">Narrate All Chat</span>
440 </div>`));
441 $('#ttsExtensionMenuItem').attr('title', t`TTS play/pause`).attr('data-i18n', '[title]TTS play/pause').on('click', onAudioControlClicked);
442 $('#ttsExtensionNarrateAll').attr('title', t`Narrate all messages in the current chat. Includes user messages, excludes hidden comments.`).attr('data-i18n', '[title]Narrate all messages in the current chat. Includes user messages, excludes hidden comments.').on('click', playFullConversation);
443 updateUiAudioPlayState();
444}
445
446function completeCurrentAudioJob() {
447 audioQueueProcessorReady = true;
448 currentAudioJob = null;
449 // updateUiPlayState();
450 wrapper.update();
451}
452
453/**
454 * Accepts an HTTP response containing audio/mpeg data, and puts the data as a Blob() on the queue for playback
455 * @param {Response} response
456 * @param {string} char
457 * @returns {Promise<{audioBlob: Blob|string, mimeType: string}>}
458 */
459async function addAudioJob(response, char) {
460 let audioBlob, mimeType;
461 if (typeof response === 'string') {
462 audioBlob = response;
463 mimeType = '';
464 } else {
465 audioBlob = await response.blob();
466 if (!audioBlob.type.startsWith('audio/')) {
467 throw `TTS received HTTP response with invalid data format. Expecting audio/*, got ${audioBlob.type}`;
468 }
469 mimeType = audioBlob.type;
470 }
471 audioJobQueue.push({ audioBlob, char });
472 console.debug('Pushed audio job to queue.');
473 return { audioBlob, mimeType };
474}
475
476async function processAudioJobQueue() {
477 // Nothing to do, audio not completed, or audio paused - stop processing.
478 if (audioJobQueue.length == 0 || !audioQueueProcessorReady || audioPaused) {
479 return;
480 }
481 try {
482 audioQueueProcessorReady = false;
483 currentAudioJob = audioJobQueue.shift();
484 playAudioData(currentAudioJob);
485 } catch (error) {
486 toastr.error(error.toString());
487 console.error(error);
488 audioQueueProcessorReady = true;
489 }
490}
491
492//################//
493// TTS Control //
494//################//
495
496/** @type {TtsMessage[]} */
497const ttsJobQueue = [];
498/** @type {TtsMessage|null} */
499let currentTtsJob = null; // Null if nothing is currently being processed
500
501function completeTtsJob() {
502 console.info(`Current TTS job for ${currentTtsJob?.name} completed.`);
503 currentTtsJob = null;
504}
505
506async function tts(text, voiceId, char, voiceMapKey = null) {
507 const messageId = currentTtsJob?.id ?? null;
508
509 await eventSource.emit(event_types.TTS_JOB_STARTED, { messageId, characterName: char, text, voiceId });
510
511 async function processResponse(response) {
512 // RVC injection
513 if (typeof globalThis.rvcVoiceConversion === 'function' && extension_settings.rvc.enabled)
514 response = await globalThis.rvcVoiceConversion(response, char, text);
515
516 const audioResult = await addAudioJob(response, char);
517 const eventData = { messageId, characterName: char, text, audio: audioResult.audioBlob, mimeType: audioResult.mimeType };
518 await eventSource.emit(event_types.TTS_AUDIO_READY, eventData);
519 }
520
521 // voiceMapKey can also include segment qualifiers, e.g. '{char} ("Quotes")'
522 let response = await ttsProvider.generateTts(text, voiceId, voiceMapKey);
523
524 // If async generator, process every chunk as it comes in
525 if (typeof response[Symbol.asyncIterator] === 'function') {
526 for await (const chunk of response) {
527 await processResponse(chunk);
528 }
529 } else {
530 await processResponse(response);
531 }
532
533 await eventSource.emit(event_types.TTS_JOB_COMPLETE, { messageId, characterName: char });
534 completeTtsJob();
535}
536
537function parseMessageSegments(text) {
538 if (!extension_settings.tts.multi_voice_enabled) {
539 return [{ type: 'other', text: text }];
540 }
541
542 const segments = [];
543 const segmentRegex = /(\*[^*]*?\*)|(".*?")|(\u201C.*?\u201D)|(\u00AB.*?\u00BB)|(\u300C.*?\u300D)|(\u300E.*?\u300F)|(\uFF02.*?\uFF02)/gim;
544 let lastIndex = 0;
545 let match;
546
547 segmentRegex.lastIndex = 0;
548
549 while ((match = segmentRegex.exec(text)) !== null) {
550 // Add other text before this match
551 if (match.index > lastIndex) {
552 const otherText = text.substring(lastIndex, match.index).trim();
553 if (otherText && otherText.length > 0) {
554 segments.push({ type: 'other', text: otherText });
555 }
556 }
557
558 const matchedText = match[0];
559 let segmentType = 'other';
560 let content = '';
561
562 if (match[1]) {
563 // Asterisk content (*action*)
564 segmentType = 'action';
565 content = matchedText.slice(1, -1);
566 } else if (match[2] || match[3] || match[4] || match[5] || match[6] || match[7]) {
567 // Various quote types ("dialogue")
568 segmentType = 'dialogue';
569 content = matchedText.slice(1, -1);
570 }
571
572 // Trim and check for actual content
573 content = content.trim();
574 if (content.length > 0) {
575 segments.push({
576 type: segmentType,
577 text: content,
578 });
579 }
580
581 lastIndex = match.index + matchedText.length;
582 }
583
584 // Add remaining other text after last match
585 if (lastIndex < text.length) {
586 const otherText = text.substring(lastIndex).trim();
587 if (otherText.length > 0) {
588 segments.push({ type: 'other', text: otherText });
589 }
590 }
591
592 // If no segments found and not empty, treat whole text as other text
593 if (segments.length === 0 && text.trim().length > 0) {
594 segments.push({ type: 'other', text: text.trim() });
595 }
596
597 return segments;
598}
599
600async function processTtsQueue() {
601 // Called each moduleWorker iteration to pull chat messages from queue
602 if (currentTtsJob || ttsJobQueue.length <= 0 || audioPaused) {
603 return;
604 }
605
606 console.debug('New message found, running TTS');
607 currentTtsJob = ttsJobQueue.shift();
608
609 // Handle segmented jobs that already have processed text
610 if (currentTtsJob.segmentType && currentTtsJob.segmentText) {
611 const char = currentTtsJob.name;
612 const segmentText = currentTtsJob.segmentText;
613 const segmentType = currentTtsJob.segmentType;
614
615 console.log(`TTS (${segmentType}): ${segmentText}`);
616
617 try {
618 let voiceMapKey = char;
619
620 // If multi-voice is enabled, modify the voice map key based on segment type
621 if (extension_settings.tts.multi_voice_enabled && char !== DEFAULT_VOICE_MARKER) {
622 switch (segmentType) {
623 case 'dialogue':
624 voiceMapKey = `${char} ("Quotes")`;
625 break;
626 case 'action':
627 voiceMapKey = `${char} (*Text inside asterisks*)`;
628 break;
629 case 'other':
630 default:
631 voiceMapKey = `${char} (Other text)`;
632 break;
633 }
634 }
635
636 const voiceMapEntry = voiceMap[voiceMapKey] === DEFAULT_VOICE_MARKER ? voiceMap[DEFAULT_VOICE_MARKER] : voiceMap[voiceMapKey];
637
638 if (voiceMapEntry === DISABLED_VOICE_MARKER) {
639 const storageKey = `tts_disabled_warned_${char}`;
640 if (!accountStorage.getItem(storageKey) || currentTtsJob.manual) {
641 accountStorage.setItem(storageKey, 'true');
642 toastr.info(`TTS voice for ${char} is disabled.`);
643 }
644 currentTtsJob = null;
645 setTimeout(() => wrapper.update(), 0);
646 return;
647 }
648
649 if (!voiceMapEntry) {
650 throw `${char} not in voicemap. Configure character in extension settings voice map`;
651 }
652
653 const voice = await ttsProvider.getVoice(voiceMapEntry);
654 const voiceId = voice.voice_id;
655 if (voiceId == null) {
656 toastr.error(`Specified voice for ${char} was not found. Check the TTS extension settings.`);
657 throw `Unable to attain voiceId for ${char}`;
658 }
659
660 // Pass the full voiceMapKey (e.g., "User ("Quotes")") as well with character name
661 await tts(segmentText, voiceId, char, voiceMapKey);
662 } catch (error) {
663 toastr.error(error.toString());
664 console.error(error);
665 currentTtsJob = null;
666 }
667 return;
668 }
669
670 // Process unsegmented job (first time processing)
671 let text = extension_settings.tts.narrate_translated_only ? (currentTtsJob?.extra?.display_text || currentTtsJob.mes) : currentTtsJob.mes;
672
673 // Substitute macros
674 text = substituteParams(text);
675
676 if (extension_settings.tts.skip_codeblocks) {
677 text = text.replace(/```.*?```/gs, '').trim();
678 text = text.replace(/~~~.*?~~~/gs, '').trim();
679 }
680
681 if (extension_settings.tts.skip_tags) {
682 text = text.replace(/<.*?>[\s\S]*?<\/.*?>/g, '').trim();
683 }
684
685 if (!extension_settings.tts.pass_asterisks) {
686 text = extension_settings.tts.narrate_dialogues_only
687 ? text.replace(/\*[^*]*?(\*|$)/g, '').trim() // remove asterisks content
688 : text.replaceAll('*', '').trim(); // remove just the asterisks
689 }
690
691 if (extension_settings.tts.apply_regex && extension_settings.tts.regex_pattern) {
692 const regex = regexFromString(extension_settings.tts.regex_pattern);
693 if (regex) {
694 // Clean up extra spaces that might be left after removal
695 text = text.replace(regex, '').replace(/\s+/g, ' ').trim();
696 } else {
697 console.warn('Invalid regex pattern:', extension_settings.tts.regex_pattern);
698 }
699 }
700
701 if (extension_settings.tts.narrate_quoted_only) {
702 const partJoiner = (ttsProvider?.separator || ' ... ');
703 text = joinQuotedBlocks(text, { separator: partJoiner, includeQuotes: true });
704 }
705
706 // Remove embedded images
707 text = text.replace(/!\[.*?]\([^)]*\)/g, '');
708
709 if (typeof ttsProvider?.processText === 'function') {
710 text = await ttsProvider.processText(text);
711 }
712
713 // Collapse newlines and spaces into single space
714 text = text.replace(/\s+/g, ' ').trim();
715
716 console.log(`TTS: ${text}`);
717 const char = currentTtsJob.name;
718
719 // Remove character name from start of the line if power user setting is disabled
720 if (char && !power_user.allow_name2_display) {
721 const escapedChar = escapeRegex(char);
722 text = text.replace(new RegExp(`^${escapedChar}:`, 'gm'), '');
723 }
724
725 try {
726 if (!text) {
727 console.warn('Got empty text in TTS queue job.');
728 completeTtsJob();
729 return;
730 }
731
732 // Parse message into segments if multi-voice is enabled
733 const segments = parseMessageSegments(text);
734
735 if (segments.length === 0) {
736 console.warn('No valid segments found in text.');
737 completeTtsJob();
738 return;
739 }
740
741 // Add all segments to the queue as separate jobs (in reverse order so they process in correct order)
742 for (let i = segments.length - 1; i >= 0; i--) {
743 const segmentJob = {
744 name: char,
745 segmentType: segments[i].type,
746 segmentText: segments[i].text,
747 is_user: currentTtsJob.is_user,
748 mes: currentTtsJob.mes,
749 extra: currentTtsJob.extra,
750 id: currentTtsJob.id,
751 manual: currentTtsJob.manual,
752 };
753 ttsJobQueue.unshift(segmentJob);
754 }
755
756 // Clear current job so the segmented jobs can be processed
757 currentTtsJob = null;
758 } catch (error) {
759 toastr.error(error.toString());
760 console.error(error);
761 currentTtsJob = null;
762 }
763}
764
765/**
766 * Extract and join quoted blocks with proper matching pairs and nesting.
767 * - Captures outermost quotes and everything inside (including different inner quote styles).
768 * - Requires matching opener/closer style (e.g., “ ... ”, 「 ... 」, « ... », etc.).
769 * - Ignores incomplete/unclosed quotes (doesn't include them in the result).
770 * - Symmetric quotes like "..." and "..." are supported (not nesting the same symmetric style).
771 *
772 * @param {string} text - The text to process
773 * @param {object} [opts={}] - Optional options object
774 * @param {string} [opts.separator=' ... '] - String to join multiple quoted blocks
775 * @param {boolean} [opts.includeQuotes=true] - Keep the quote chars around the captured text
776 * @param {boolean} [opts.returnEmptyOnNoQuotes=false] - Return an empty string if no quotes are found
777 * @param {Array<[string,string]>} [opts.pairs] - Custom quote pairs; defaults cover EN/DE/FR/JP
778 * @returns {string} The joined quoted blocks, or the original text if no quotes found
779 */
780function joinQuotedBlocks(text, opts = {}) {
781 const {
782 separator = ' ... ',
783 includeQuotes = true,
784 returnEmptyOnNoQuotes = false,
785 pairs = [
786 // typographic doubles
787 ['„', '“'], // DE low-high
788 ['“', '”'], // EN
789 ['«', '»'], // FR open « close »
790 ['»', '«'], // Some locales open »
791 // typographic singles
792 ['‘', '’'],
793 ['‚', '‘'],
794 // Japanese corner quotes
795 ['「', '」'],
796 ['『', '』'],
797 // symmetric doubles
798 ['"', '"'],
799 ['"', '"'],
800 ],
801 } = opts;
802
803 if (!text || typeof text !== 'string') return text;
804
805 const openToClose = Object.fromEntries(pairs);
806
807 const segments = [];
808 const stack = []; // [{ opener, expectedClose, start }]
809 for (let i = 0; i < text.length; i++) {
810 const ch = text[i];
811 const top = stack[stack.length - 1];
812
813 // Prefer closing the current open pair if the char matches its expected closer
814 if (top && ch === top.expectedClose) {
815 const finished = stack.pop();
816 if (stack.length === 0) {
817 // Only collect outermost quotes (contains all nested content)
818 segments.push(text.slice(finished.start, i + 1));
819 }
820 continue;
821 }
822
823 // Otherwise, see if this is a new opener
824 if (openToClose[ch]) {
825 stack.push({ opener: ch, expectedClose: openToClose[ch], start: i });
826 continue;
827 }
828
829 // If it's a stray closer that doesn't match current top, ignore
830 }
831
832 if (!segments.length) return returnEmptyOnNoQuotes ? '' : text;
833
834 const cleaned = includeQuotes
835 ? segments
836 : segments.map(s => s.slice(1, -1)); // all defined pairs are single-char quotes
837
838 return cleaned.join(separator);
839}
840
841async function playFullConversation() {
842 resetTtsPlayback();
843
844 if (!extension_settings.tts.enabled) {
845 return toastr.warning('TTS is disabled. Please enable it in the extension settings.');
846 }
847
848 const context = getContext();
849
850 context.chat.forEach((msg, i) => {
851 if (!msg.is_system && msg.mes !== '...' && msg.mes !== '') {
852 processAndQueueTtsMessage(msg, i, { manual: false });
853 }
854 });
855
856 if (ttsJobQueue.length === 0) {
857 return toastr.info('No messages to narrate.');
858 }
859}
860
861globalThis.playFullConversation = playFullConversation;
862
863//#############################//
864// Extension UI and Settings //
865//#############################//
866
867function loadSettings() {
868 if (Object.keys(extension_settings.tts).length === 0) {
869 Object.assign(extension_settings.tts, defaultSettings);
870 }
871 for (const key in defaultSettings) {
872 if (!(key in extension_settings.tts)) {
873 extension_settings.tts[key] = defaultSettings[key];
874 }
875 }
876 $('#tts_provider').val(extension_settings.tts.currentProvider);
877 $('#tts_enabled').prop(
878 'checked',
879 extension_settings.tts.enabled,
880 );
881 $('#tts_narrate_dialogues').prop('checked', extension_settings.tts.narrate_dialogues_only);
882 $('#tts_narrate_quoted').prop('checked', extension_settings.tts.narrate_quoted_only);
883 $('#tts_auto_generation').prop('checked', extension_settings.tts.auto_generation);
884 $('#tts_periodic_auto_generation').prop('checked', extension_settings.tts.periodic_auto_generation);
885 $('#tts_narrate_by_paragraphs').prop('checked', extension_settings.tts.narrate_by_paragraphs);
886 $('#tts_narrate_translated_only').prop('checked', extension_settings.tts.narrate_translated_only);
887 $('#tts_narrate_user').prop('checked', extension_settings.tts.narrate_user);
888 $('#tts_pass_asterisks').prop('checked', extension_settings.tts.pass_asterisks);
889 $('#tts_skip_codeblocks').prop('checked', extension_settings.tts.skip_codeblocks);
890 $('#tts_skip_tags').prop('checked', extension_settings.tts.skip_tags);
891 $('#tts_multi_voice_enabled').prop('checked', extension_settings.tts.multi_voice_enabled);
892 $('#tts_apply_regex').prop('checked', extension_settings.tts.apply_regex);
893 $('#tts_regex_pattern').val(extension_settings.tts.regex_pattern);
894 $('#tts_regex_block').toggle(extension_settings.tts.apply_regex);
895 updateRegexPatternWarning();
896 $('#playback_rate').val(extension_settings.tts.playback_rate);
897 $('#playback_rate_counter').val(Number(extension_settings.tts.playback_rate).toFixed(2));
898 $('#playback_rate_block').toggle(extension_settings.tts.currentProvider !== 'System');
899
900 $('body').toggleClass('tts', extension_settings.tts.enabled);
901}
902
903const defaultSettings = {
904 voiceMap: '',
905 ttsEnabled: false,
906 currentProvider: 'ElevenLabs',
907 auto_generation: true,
908 narrate_user: false,
909 playback_rate: 1,
910 multi_voice_enabled: false,
911 apply_regex: false,
912 regex_pattern: '',
913};
914
915function setTtsStatus(status, success) {
916 $('#tts_status').text(status);
917 if (success) {
918 $('#tts_status').removeAttr('style');
919 } else {
920 $('#tts_status').css('color', 'red');
921 }
922}
923
924function onRefreshClick() {
925 Promise.all([
926 ttsProvider.onRefreshClick(),
927 // updateVoiceMap()
928 ]).then(() => {
929 extension_settings.tts[ttsProviderName] = ttsProvider.settings;
930 saveSettingsDebounced();
931 setTtsStatus('Successfully applied settings', true);
932 console.info(`Saved settings ${ttsProviderName} ${JSON.stringify(ttsProvider.settings)}`);
933 initVoiceMap();
934 updateVoiceMap();
935 }).catch(error => {
936 toastr.error(error.toString());
937 console.error(error);
938 setTtsStatus(error, false);
939 });
940}
941
942function onEnableClick() {
943 extension_settings.tts.enabled = $('#tts_enabled').is(
944 ':checked',
945 );
946 updateUiAudioPlayState();
947 saveSettingsDebounced();
948 $('body').toggleClass('tts', extension_settings.tts.enabled);
949}
950
951
952function onAutoGenerationClick() {
953 extension_settings.tts.auto_generation = !!$('#tts_auto_generation').prop('checked');
954 saveSettingsDebounced();
955}
956
957
958function onPeriodicAutoGenerationClick() {
959 extension_settings.tts.periodic_auto_generation = !!$('#tts_periodic_auto_generation').prop('checked');
960 saveSettingsDebounced();
961}
962
963function onNarrateByParagraphsClick() {
964 extension_settings.tts.narrate_by_paragraphs = !!$('#tts_narrate_by_paragraphs').prop('checked');
965 saveSettingsDebounced();
966}
967
968
969function onNarrateDialoguesClick() {
970 extension_settings.tts.narrate_dialogues_only = !!$('#tts_narrate_dialogues').prop('checked');
971 saveSettingsDebounced();
972}
973
974function onNarrateUserClick() {
975 extension_settings.tts.narrate_user = !!$('#tts_narrate_user').prop('checked');
976 saveSettingsDebounced();
977}
978
979function onNarrateQuotedClick() {
980 extension_settings.tts.narrate_quoted_only = !!$('#tts_narrate_quoted').prop('checked');
981 saveSettingsDebounced();
982}
983
984
985function onNarrateTranslatedOnlyClick() {
986 extension_settings.tts.narrate_translated_only = !!$('#tts_narrate_translated_only').prop('checked');
987 saveSettingsDebounced();
988}
989
990function onSkipCodeblocksClick() {
991 extension_settings.tts.skip_codeblocks = !!$('#tts_skip_codeblocks').prop('checked');
992 saveSettingsDebounced();
993}
994
995function onSkipTagsClick() {
996 extension_settings.tts.skip_tags = !!$('#tts_skip_tags').prop('checked');
997 saveSettingsDebounced();
998}
999
1000function onPassAsterisksClick() {
1001 extension_settings.tts.pass_asterisks = !!$('#tts_pass_asterisks').prop('checked');
1002 saveSettingsDebounced();
1003 console.log('setting pass asterisks', extension_settings.tts.pass_asterisks);
1004}
1005
1006function onMultiVoiceClick() {
1007 extension_settings.tts.multi_voice_enabled = !!$('#tts_multi_voice_enabled').prop('checked');
1008 saveSettingsDebounced();
1009 // Reinitialize voice map to show/hide voices
1010 initVoiceMap();
1011}
1012
1013function onApplyRegexChange() {
1014 extension_settings.tts.apply_regex = !!$('#tts_apply_regex').prop('checked');
1015 saveSettingsDebounced();
1016 $('#tts_regex_block').toggle(extension_settings.tts.apply_regex);
1017 updateRegexPatternWarning();
1018}
1019
1020function onRegexPatternChange() {
1021 extension_settings.tts.regex_pattern = $('#tts_regex_pattern').val().toString();
1022 saveSettingsDebounced();
1023 updateRegexPatternWarning();
1024}
1025
1026function updateRegexPatternWarning() {
1027 const warning = $('#tts_regex_warning');
1028 if (!extension_settings.tts.apply_regex) {
1029 warning.hide();
1030 return;
1031 }
1032
1033 const pattern = extension_settings.tts.regex_pattern;
1034 if (!pattern) {
1035 warning.hide();
1036 return;
1037 }
1038
1039 const regex = regexFromString(pattern);
1040 warning.toggle(!regex);
1041}
1042
1043//##############//
1044// TTS Provider //
1045//##############//
1046
1047async function loadTtsProvider(provider) {
1048 //Clear the current config and add new config
1049 $('#tts_provider_settings').html('');
1050
1051 if (!provider) {
1052 return;
1053 }
1054
1055 // Init provider references
1056 extension_settings.tts.currentProvider = provider;
1057 ttsProviderName = provider;
1058 ttsProvider = new ttsProviders[provider];
1059
1060 // Init provider settings
1061 $('#tts_provider_settings').append(ttsProvider.settingsHtml);
1062 if (!(ttsProviderName in extension_settings.tts)) {
1063 console.warn(`Provider ${ttsProviderName} not in Extension Settings, initiatilizing provider in settings`);
1064 extension_settings.tts[ttsProviderName] = {};
1065 }
1066 await ttsProvider.loadSettings(extension_settings.tts[ttsProviderName]);
1067 await initVoiceMap();
1068}
1069
1070function onTtsProviderChange() {
1071 if (typeof ttsProvider?.dispose === 'function') {
1072 ttsProvider.dispose();
1073 }
1074 const ttsProviderSelection = $('#tts_provider').val();
1075 extension_settings.tts.currentProvider = ttsProviderSelection;
1076 $('#playback_rate_block').toggle(extension_settings.tts.currentProvider !== 'System');
1077 loadTtsProvider(ttsProviderSelection);
1078}
1079
1080// Ensure that TTS provider settings are saved to extension settings.
1081export function saveTtsProviderSettings() {
1082 extension_settings.tts[ttsProviderName] = ttsProvider.settings;
1083 updateVoiceMap();
1084 saveSettingsDebounced();
1085 console.info(`Saved settings ${ttsProviderName} ${JSON.stringify(ttsProvider.settings)}`);
1086}
1087
1088
1089//###################//
1090// voiceMap Handling //
1091//###################//
1092
1093async function onChatChanged() {
1094 await onGenerationEnded();
1095 resetTtsPlayback();
1096 const voiceMapInit = initVoiceMap();
1097 await Promise.race([voiceMapInit, delay(debounce_timeout.relaxed)]);
1098 lastMessage = null;
1099}
1100
1101async function onMessageEvent(messageId, lastCharIndex) {
1102 // If TTS is disabled, do nothing
1103 if (!extension_settings.tts.enabled) {
1104 return;
1105 }
1106
1107 // Auto generation is disabled
1108 if (!extension_settings.tts.auto_generation) {
1109 return;
1110 }
1111
1112 const context = getContext();
1113
1114 // no characters or group selected
1115 if (!context.groupId && context.characterId === undefined) {
1116 return;
1117 }
1118
1119 // Chat changed
1120 if (context.chatId !== lastChatId) {
1121 lastChatId = context.chatId;
1122 lastMessageHash = getStringHash(context.chat[messageId]?.mes ?? '');
1123
1124 // Force to speak on the first message in the new chat
1125 if (context.chat.length === 1) {
1126 lastMessageHash = -1;
1127 }
1128 }
1129
1130 // clone message object, as things go haywire if message object is altered below (it's passed by reference)
1131 /** @type {TtsMessage} */
1132 const message = structuredClone(context.chat[messageId]);
1133 const hashNew = getStringHash(message?.mes ?? '');
1134
1135 // Ignore prompt-hidden messages
1136 if (message.is_system) {
1137 return;
1138 }
1139
1140 // if no new messages, or same message, or same message hash, do nothing
1141 if (hashNew === lastMessageHash) {
1142 return;
1143 }
1144
1145 // if we only want to process part of the message
1146 if (lastCharIndex) {
1147 message.mes = message.mes.substring(0, lastCharIndex);
1148 }
1149
1150 const isLastMessageInCurrent = () =>
1151 lastMessage &&
1152 typeof lastMessage === 'object' &&
1153 message.swipe_id === lastMessage.swipe_id &&
1154 message.name === lastMessage.name &&
1155 message.is_user === lastMessage.is_user &&
1156 message.mes.indexOf(lastMessage.mes) !== -1;
1157
1158 // if last message within current message, message got extended. only send diff to TTS.
1159 if (isLastMessageInCurrent()) {
1160 const tmp = structuredClone(message);
1161 message.mes = message.mes.replace(lastMessage.mes, '');
1162 lastMessage = tmp;
1163 } else {
1164 lastMessage = structuredClone(message);
1165 }
1166
1167 // We're currently swiping. Don't generate voice
1168 if (!message || message.mes === '...' || message.mes === '') {
1169 return;
1170 }
1171
1172 // Don't generate if message doesn't have a display text
1173 if (extension_settings.tts.narrate_translated_only && !(message?.extra?.display_text)) {
1174 return;
1175 }
1176
1177 // Don't generate if message is a user message and user message narration is disabled
1178 if (message.is_user && !extension_settings.tts.narrate_user) {
1179 return;
1180 }
1181
1182 // New messages, add new chat to history
1183 lastMessageHash = hashNew;
1184 lastChatId = context.chatId;
1185
1186 console.debug(`Adding message from ${message.name} for TTS processing: "${message.mes}"`);
1187
1188 if (extension_settings.tts.periodic_auto_generation && isStreamingEnabled()) {
1189 message.id = messageId;
1190 ttsJobQueue.push(message);
1191 } else {
1192 processAndQueueTtsMessage(message, messageId, { manual: false });
1193 }
1194}
1195
1196async function onMessageDeleted() {
1197 const context = getContext();
1198
1199 // update internal references to new last message
1200 lastChatId = context.chatId;
1201
1202 // compare against lastMessageHash. If it's the same, we did not delete the last chat item, so no need to reset tts queue
1203 const messageHash = getStringHash((context.chat.length && context.chat[context.chat.length - 1].mes) ?? '');
1204 if (messageHash === lastMessageHash) {
1205 return;
1206 }
1207 lastMessageHash = messageHash;
1208 lastMessage = context.chat.length ? structuredClone(context.chat[context.chat.length - 1]) : null;
1209
1210 // stop any tts playback since message might not exist anymore
1211 resetTtsPlayback();
1212}
1213
1214async function onGenerationStarted(generationType, _args, isDryRun) {
1215 // If dry running or quiet mode, do nothing
1216 if (isDryRun || ['quiet', 'impersonate'].includes(generationType)) {
1217 return;
1218 }
1219
1220 // If TTS is disabled, do nothing
1221 if (!extension_settings.tts.enabled) {
1222 return;
1223 }
1224
1225 // Auto generation is disabled
1226 if (!extension_settings.tts.auto_generation) {
1227 return;
1228 }
1229
1230 // Periodic auto generation is disabled
1231 if (!extension_settings.tts.periodic_auto_generation) {
1232 return;
1233 }
1234
1235 // If the reply is not being streamed
1236 if (!isStreamingEnabled()) {
1237 return;
1238 }
1239
1240 // start the timer
1241 if (!periodicMessageGenerationTimer) {
1242 periodicMessageGenerationTimer = setInterval(onPeriodicMessageGenerationTick, UPDATE_INTERVAL);
1243 }
1244}
1245
1246async function onGenerationEnded() {
1247 if (periodicMessageGenerationTimer) {
1248 clearInterval(periodicMessageGenerationTimer);
1249 periodicMessageGenerationTimer = null;
1250 }
1251 lastPositionOfParagraphEnd = -1;
1252}
1253
1254async function onPeriodicMessageGenerationTick() {
1255 const context = getContext();
1256
1257 // no characters or group selected
1258 if (!context.groupId && context.characterId === undefined) {
1259 return;
1260 }
1261
1262 const lastMessageId = context.chat.length - 1;
1263
1264 // the last message was from the user
1265 if (context.chat[lastMessageId].is_user) {
1266 return;
1267 }
1268
1269 const lastMessage = structuredClone(context.chat[lastMessageId]);
1270 const lastMessageText = lastMessage?.mes ?? '';
1271
1272 // look for double ending lines which should indicate the end of a paragraph
1273 let newLastPositionOfParagraphEnd = lastMessageText
1274 .indexOf('\n\n', lastPositionOfParagraphEnd + 1);
1275 // if not found, look for a single ending line which should indicate the end of a paragraph
1276 if (newLastPositionOfParagraphEnd === -1) {
1277 newLastPositionOfParagraphEnd = lastMessageText
1278 .indexOf('\n', lastPositionOfParagraphEnd + 1);
1279 }
1280
1281 // send the message to the tts module if we found the new end of a paragraph
1282 if (newLastPositionOfParagraphEnd > -1) {
1283 onMessageEvent(lastMessageId, newLastPositionOfParagraphEnd);
1284
1285 if (periodicMessageGenerationTimer) {
1286 lastPositionOfParagraphEnd = newLastPositionOfParagraphEnd;
1287 }
1288 }
1289}
1290
1291/**
1292 * Get characters in current chat
1293 * @param {boolean} unrestricted - If true, will include all characters in voiceMapEntries, even if they are not in the current chat.
1294 * @returns {string[]} - Array of character names
1295 */
1296export function getCharacters(unrestricted) {
1297 const context = getContext();
1298
1299 if (unrestricted) {
1300 const names = context.characters.map(char => char.name);
1301 names.unshift(DEFAULT_VOICE_MARKER);
1302 return names.filter(onlyUnique);
1303 }
1304
1305 let characters = [];
1306 if (context.groupId === null) {
1307 // Single char chat
1308 characters.push(DEFAULT_VOICE_MARKER);
1309 characters.push(context.name1);
1310 characters.push(context.name2);
1311 } else {
1312 // Group chat
1313 characters.push(DEFAULT_VOICE_MARKER);
1314 characters.push(context.name1);
1315 const group = context.groups.find(group => context.groupId == group.id);
1316 for (let member of group.members) {
1317 const character = context.characters.find(char => char.avatar == member);
1318 if (character) {
1319 characters.push(character.name);
1320 }
1321 }
1322 }
1323 characters = characters.filter(onlyUnique);
1324
1325 // If multi-voice is enabled, expand characters to include segment types
1326 if (extension_settings.tts.multi_voice_enabled) {
1327 const expandedCharacters = [];
1328 for (const char of characters) {
1329 if (char === DEFAULT_VOICE_MARKER || char === 'SillyTavern System') {
1330 expandedCharacters.push(char);
1331 } else {
1332 expandedCharacters.push(`${char} ("Quotes")`);
1333 expandedCharacters.push(`${char} (*Text inside asterisks*)`);
1334 expandedCharacters.push(`${char} (Other text)`);
1335 }
1336 }
1337 return expandedCharacters;
1338 }
1339
1340 return characters;
1341}
1342
1343export function sanitizeId(input) {
1344 // Remove any non-alphanumeric characters except underscore (_) and hyphen (-)
1345 let sanitized = encodeURIComponent(input).replace(/[^a-zA-Z0-9-_]/g, '');
1346
1347 // Ensure first character is always a letter
1348 if (!/^[a-zA-Z]/.test(sanitized)) {
1349 sanitized = 'element_' + sanitized;
1350 }
1351
1352 return sanitized;
1353}
1354
1355function parseVoiceMap(voiceMapString) {
1356 let parsedVoiceMap = {};
1357 for (const [charName, voiceId] of voiceMapString
1358 .split(',')
1359 .map(s => s.split(':'))) {
1360 if (charName && voiceId) {
1361 parsedVoiceMap[charName.trim()] = voiceId.trim();
1362 }
1363 }
1364 return parsedVoiceMap;
1365}
1366
1367
1368/**
1369 * Apply voiceMap based on current voiceMapEntries
1370 */
1371function updateVoiceMap() {
1372 const tempVoiceMap = {};
1373 for (const voice of voiceMapEntries) {
1374 if (voice.voiceId === null) {
1375 continue;
1376 }
1377 tempVoiceMap[voice.name] = voice.voiceId;
1378 }
1379 if (Object.keys(tempVoiceMap).length !== 0) {
1380 voiceMap = tempVoiceMap;
1381 console.log(`Voicemap updated to ${JSON.stringify(voiceMap)}`);
1382 }
1383 if (!extension_settings.tts[ttsProviderName].voiceMap) {
1384 extension_settings.tts[ttsProviderName].voiceMap = {};
1385 }
1386 Object.assign(extension_settings.tts[ttsProviderName].voiceMap, voiceMap);
1387 saveSettingsDebounced();
1388}
1389
1390class VoiceMapEntry {
1391 name;
1392 voiceId;
1393 selectElement;
1394 constructor(name, voiceId = DEFAULT_VOICE_MARKER) {
1395 this.name = name;
1396 this.voiceId = voiceId;
1397 this.selectElement = null;
1398 }
1399
1400 addUI(voiceIds) {
1401 let sanitizedName = sanitizeId(this.name);
1402 let defaultOption = this.name === DEFAULT_VOICE_MARKER ?
1403 `<option>${DISABLED_VOICE_MARKER}</option>` :
1404 `<option>${DEFAULT_VOICE_MARKER}</option><option>${DISABLED_VOICE_MARKER}</option>`;
1405 let template = `
1406 <div class='tts_voicemap_block_char flex-container flexGap5'>
1407 <span id='tts_voicemap_char_${sanitizedName}'>${this.name}</span>
1408 <select id='tts_voicemap_char_${sanitizedName}_voice'>
1409 ${defaultOption}
1410 </select>
1411 </div>
1412 `;
1413 $('#tts_voicemap_block').append(template);
1414
1415 // Populate voice ID select list
1416 for (const voiceId of voiceIds) {
1417 const option = document.createElement('option');
1418 option.innerText = voiceId.name;
1419 option.value = voiceId.name;
1420 $(`#tts_voicemap_char_${sanitizedName}_voice`).append(option);
1421 }
1422
1423 this.selectElement = $(`#tts_voicemap_char_${sanitizedName}_voice`);
1424 this.selectElement.on('change', args => this.onSelectChange(args));
1425 this.selectElement.val(this.voiceId);
1426 }
1427
1428 onSelectChange(args) {
1429 this.voiceId = this.selectElement.find(':selected').val();
1430 updateVoiceMap();
1431 }
1432}
1433
1434/**
1435 * Init voiceMapEntries for character select list.
1436 * If an initialization is already in progress, it returns the existing Promise instead of starting a new one.
1437 * @param {boolean} unrestricted - If true, will include all characters in voiceMapEntries, even if they are not in the current chat.
1438 * @returns {Promise} A promise that resolves when the initialization is complete.
1439 */
1440export async function initVoiceMap(unrestricted = false) {
1441 // Preventing parallel execution
1442 if (currentInitVoiceMapPromise) {
1443 return currentInitVoiceMapPromise;
1444 }
1445
1446 currentInitVoiceMapPromise = (async () => {
1447 const initialChatId = getCurrentChatId();
1448 try {
1449 await initVoiceMapInternal(unrestricted);
1450 } finally {
1451 currentInitVoiceMapPromise = null;
1452 }
1453 const currentChatId = getCurrentChatId();
1454
1455 if (initialChatId !== currentChatId) {
1456 // Chat changed during initialization, reinitialize
1457 await initVoiceMap(unrestricted);
1458 }
1459 })();
1460
1461 return currentInitVoiceMapPromise;
1462}
1463
1464/**
1465 * Init voiceMapEntries for character select list.
1466 * @param {boolean} unrestricted - If true, will include all characters in voiceMapEntries, even if they are not in the current chat.
1467 */
1468async function initVoiceMapInternal(unrestricted) {
1469 // Gate initialization if not enabled or TTS Provider not ready. Prevents error popups.
1470 const enabled = $('#tts_enabled').is(':checked');
1471 if (!enabled) {
1472 return;
1473 }
1474
1475 // Keep errors inside extension UI rather than toastr. Toastr errors for TTS are annoying.
1476 try {
1477 await ttsProvider.checkReady();
1478 } catch (error) {
1479 const message = `TTS Provider not ready. ${error}`;
1480 setTtsStatus(message, false);
1481 return;
1482 }
1483
1484 setTtsStatus('TTS Provider Loaded', true);
1485
1486 // Clear existing voiceMap state
1487 $('#tts_voicemap_block').empty();
1488 voiceMapEntries = [];
1489
1490 // Get characters in current chat
1491 const characters = getCharacters(unrestricted);
1492
1493 // Get saved voicemap from provider settings, handling new and old representations
1494 let voiceMapFromSettings = {};
1495 if ('voiceMap' in extension_settings.tts[ttsProviderName]) {
1496 // Handle previous representation
1497 if (typeof extension_settings.tts[ttsProviderName].voiceMap === 'string') {
1498 voiceMapFromSettings = parseVoiceMap(extension_settings.tts[ttsProviderName].voiceMap);
1499 // Handle new representation
1500 } else if (typeof extension_settings.tts[ttsProviderName].voiceMap === 'object') {
1501 voiceMapFromSettings = extension_settings.tts[ttsProviderName].voiceMap;
1502 }
1503 }
1504
1505 // Get voiceIds from provider
1506 let voiceIdsFromProvider;
1507 try {
1508 voiceIdsFromProvider = await ttsProvider.fetchTtsVoiceObjects();
1509 } catch {
1510 toastr.error('TTS Provider failed to return voice ids.');
1511 }
1512
1513 // Build UI using VoiceMapEntry objects
1514 for (const character of characters) {
1515 if (character === 'SillyTavern System') {
1516 continue;
1517 }
1518 // Check provider settings for voiceIds
1519 let voiceId;
1520 if (character in voiceMapFromSettings) {
1521 voiceId = voiceMapFromSettings[character];
1522 } else if (character === DEFAULT_VOICE_MARKER) {
1523 voiceId = DISABLED_VOICE_MARKER;
1524 } else {
1525 voiceId = DEFAULT_VOICE_MARKER;
1526 }
1527 const voiceMapEntry = new VoiceMapEntry(character, voiceId);
1528 voiceMapEntry.addUI(voiceIdsFromProvider);
1529 voiceMapEntries.push(voiceMapEntry);
1530 }
1531 updateVoiceMap();
1532}
1533
1534export async function init() {
1535 async function addExtensionControls() {
1536 const settingsHtml = $(await renderExtensionTemplateAsync('tts', 'settings'));
1537 $('#tts_container').append(settingsHtml);
1538 $('#tts_refresh').on('click', onRefreshClick);
1539 $('#tts_enabled').on('click', onEnableClick);
1540 $('#tts_narrate_dialogues').on('click', onNarrateDialoguesClick);
1541 $('#tts_narrate_quoted').on('click', onNarrateQuotedClick);
1542 $('#tts_narrate_translated_only').on('click', onNarrateTranslatedOnlyClick);
1543 $('#tts_skip_codeblocks').on('click', onSkipCodeblocksClick);
1544 $('#tts_skip_tags').on('click', onSkipTagsClick);
1545 $('#tts_pass_asterisks').on('click', onPassAsterisksClick);
1546 $('#tts_auto_generation').on('click', onAutoGenerationClick);
1547 $('#tts_periodic_auto_generation').on('click', onPeriodicAutoGenerationClick);
1548 $('#tts_narrate_by_paragraphs').on('click', onNarrateByParagraphsClick);
1549 $('#tts_narrate_user').on('click', onNarrateUserClick);
1550 $('#tts_multi_voice_enabled').on('click', onMultiVoiceClick);
1551 $('#tts_apply_regex').on('change', onApplyRegexChange);
1552 $('#tts_regex_pattern').on('input', onRegexPatternChange);
1553
1554 $('#playback_rate').on('input', function () {
1555 const value = $(this).val();
1556 const formattedValue = Number(value).toFixed(2);
1557 extension_settings.tts.playback_rate = value;
1558 $('#playback_rate_counter').val(formattedValue);
1559 saveSettingsDebounced();
1560 });
1561
1562 $('#tts_voices').on('click', onTtsVoicesClick);
1563 for (const provider in ttsProviders) {
1564 $('#tts_provider').append($('<option />').val(provider).text(provider));
1565 }
1566 $('#tts_provider').on('change', onTtsProviderChange);
1567 $(document).on('click', '.mes_narrate', onNarrateOneMessage);
1568 }
1569 await addExtensionControls(); // No init dependencies
1570 loadSettings(); // Depends on Extension Controls and loadTtsProvider
1571 loadTtsProvider(extension_settings.tts.currentProvider); // No dependencies
1572 addAudioControl(); // Depends on Extension Controls
1573 setInterval(wrapper.update.bind(wrapper), UPDATE_INTERVAL); // Init depends on all the things
1574 eventSource.on(event_types.MESSAGE_SWIPED, resetTtsPlayback);
1575 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
1576 eventSource.on(event_types.MESSAGE_DELETED, onMessageDeleted);
1577 eventSource.on(event_types.GROUP_UPDATED, onChatChanged);
1578 eventSource.on(event_types.GENERATION_STARTED, onGenerationStarted);
1579 eventSource.on(event_types.GENERATION_ENDED, onGenerationEnded);
1580 eventSource.makeLast(event_types.CHARACTER_MESSAGE_RENDERED, (messageId) => onMessageEvent(messageId));
1581 eventSource.makeLast(event_types.USER_MESSAGE_RENDERED, (messageId) => onMessageEvent(messageId));
1582 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1583 name: 'speak',
1584 callback: async (args, value) => {
1585 await onNarrateText(args, value);
1586 return '';
1587 },
1588 aliases: ['narrate', 'tts'],
1589 namedArgumentList: [
1590 SlashCommandNamedArgument.fromProps({
1591 name: 'voice',
1592 description: 'character voice name',
1593 typeList: [ARGUMENT_TYPE.STRING],
1594 isRequired: false,
1595 enumProvider: () => Object.keys(voiceMap).map(voiceName => new SlashCommandEnumValue(voiceName, null, enumTypes.enum, enumIcons.voice)),
1596 }),
1597 ],
1598 unnamedArgumentList: [
1599 new SlashCommandArgument(
1600 'text', [ARGUMENT_TYPE.STRING], true,
1601 ),
1602 ],
1603 helpString: `
1604 <div>
1605 Narrate any text using currently selected character's voice.
1606 </div>
1607 <div>
1608 Use <code>voice="Character Name"</code> argument to set other voice from the voice map.
1609 </div>
1610 <div>
1611 <strong>Example:</strong>
1612 <ul>
1613 <li>
1614 <pre><code>/speak voice="Donald Duck" Quack!</code></pre>
1615 </li>
1616 </ul>
1617 </div>
1618 `,
1619 }));
1620
1621 document.body.appendChild(audioElement);
1622}