Add multiple voice per character in TTS extension (#4367) * Add multiple voice per character in TTS extension * Reformat settings template --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

e71cfee4b85e382df13381e31a75ea15be20b292

Ni-co-la-s <96898279+Ni-co-la-s@users.noreply.github.com>

Signed
2 files changed, +180 -14Ignore whitespace
public/scripts/extensions/tts/index.js+170 -12
@@ -467,7 +467,7 @@ function completeTtsJob() {
467467 currentTtsJob = null;
468468}
469469
470470async function tts(text, voiceId, char, voiceMapKey = null) {
471471 async function processResponse(response) {
472472 // RVC injection
473473 if (typeof window['rvcVoiceConversion'] === 'function' && extension_settings.rvc.enabled)
@@ -476,7 +476,8 @@ async function tts(text, voiceId, char) {
476476 await addAudioJob(response, char);
477477 }
478478
479- let response = await ttsProvider.generateTts(text, voiceId, char);
479+ // voiceMapKey can also include segment qualifiers, e.g. '{char} ("Quotes")'
480+ let response = await ttsProvider.generateTts(text, voiceId, voiceMapKey);
480481
481482 // If async generator, process every chunk as it comes in
482483 if (typeof response[Symbol.asyncIterator] === 'function') {
@@ -490,6 +491,69 @@ async function tts(text, voiceId, char) {
490491 completeTtsJob();
491492}
492493
494+function parseMessageSegments(text) {
495+ if (!extension_settings.tts.multi_voice_enabled) {
496+ return [{ type: 'other', text: text }];
497+ }
498+
499+ const segments = [];
500+ const segmentRegex = /(\*[^*]*?\*)|(".*?")|(\u201C.*?\u201D)|(\u00AB.*?\u00BB)|(\u300C.*?\u300D)|(\u300E.*?\u300F)|(\uFF02.*?\uFF02)/gim;
501+ let lastIndex = 0;
502+ let match;
503+
504+ segmentRegex.lastIndex = 0;
505+
506+ while ((match = segmentRegex.exec(text)) !== null) {
507+ // Add other text before this match
508+ if (match.index > lastIndex) {
509+ const otherText = text.substring(lastIndex, match.index).trim();
510+ if (otherText && otherText.length > 0) {
511+ segments.push({ type: 'other', text: otherText });
512+ }
513+ }
514+
515+ const matchedText = match[0];
516+ let segmentType = 'other';
517+ let content = '';
518+
519+ if (match[1]) {
520+ // Asterisk content (*action*)
521+ segmentType = 'action';
522+ content = matchedText.slice(1, -1);
523+ } else if (match[2] || match[3] || match[4] || match[5] || match[6] || match[7]) {
524+ // Various quote types ("dialogue")
525+ segmentType = 'dialogue';
526+ content = matchedText.slice(1, -1);
527+ }
528+
529+ // Trim and check for actual content
530+ content = content.trim();
531+ if (content.length > 0) {
532+ segments.push({
533+ type: segmentType,
534+ text: content,
535+ });
536+ }
537+
538+ lastIndex = match.index + matchedText.length;
539+ }
540+
541+ // Add remaining other text after last match
542+ if (lastIndex < text.length) {
543+ const otherText = text.substring(lastIndex).trim();
544+ if (otherText.length > 0) {
545+ segments.push({ type: 'other', text: otherText });
546+ }
547+ }
548+
549+ // If no segments found and not empty, treat whole text as other text
550+ if (segments.length === 0 && text.trim().length > 0) {
551+ segments.push({ type: 'other', text: text.trim() });
552+ }
553+
554+ return segments;
555+}
556+
493557async function processTtsQueue() {
494558 // Called each moduleWorker iteration to pull chat messages from queue
495559 if (currentTtsJob || ttsJobQueue.length <= 0 || audioPaused) {
@@ -498,6 +562,59 @@ async function processTtsQueue() {
498562
499563 console.debug('New message found, running TTS');
500564 currentTtsJob = ttsJobQueue.shift();
565+
566+ // Handle segmented jobs that already have processed text
567+ if (currentTtsJob.segmentType && currentTtsJob.segmentText) {
568+ const char = currentTtsJob.name;
569+ const segmentText = currentTtsJob.segmentText;
570+ const segmentType = currentTtsJob.segmentType;
571+
572+ console.log(`TTS (${segmentType}): ${segmentText}`);
573+
574+ try {
575+ let voiceMapKey = char;
576+
577+ // If multi-voice is enabled, modify the voice map key based on segment type
578+ if (extension_settings.tts.multi_voice_enabled && char !== DEFAULT_VOICE_MARKER) {
579+ switch (segmentType) {
580+ case 'dialogue':
581+ voiceMapKey = `${char} ("Quotes")`;
582+ break;
583+ case 'action':
584+ voiceMapKey = `${char} (*Text inside asterisks*)`;
585+ break;
586+ case 'other':
587+ default:
588+ voiceMapKey = `${char} (Other text)`;
589+ break;
590+ }
591+ }
592+
593+ const voiceMapEntry = voiceMap[voiceMapKey] === DEFAULT_VOICE_MARKER ? voiceMap[DEFAULT_VOICE_MARKER] : voiceMap[voiceMapKey];
594+
595+ if (!voiceMapEntry || voiceMapEntry === DISABLED_VOICE_MARKER) {
596+ throw `${char} not in voicemap. Configure character in extension settings voice map`;
597+ }
598+
599+ const voice = await ttsProvider.getVoice(voiceMapEntry);
600+ const voiceId = voice.voice_id;
601+ if (voiceId == null) {
602+ toastr.error(`Specified voice for ${char} was not found. Check the TTS extension settings.`);
603+ throw `Unable to attain voiceId for ${char}`;
604+ }
605+
606+ // Pass the full voiceMapKey (e.g., "User ("Quotes")") as well with character name
607+ await tts(segmentText, voiceId, char, voiceMapKey);
608+
609+ } catch (error) {
610+ toastr.error(error.toString());
611+ console.error(error);
612+ currentTtsJob = null;
613+ }
614+ return;
615+ }
616+
617+ // Process unsegmented job (first time processing)
501618 let text = extension_settings.tts.narrate_translated_only ? (currentTtsJob?.extra?.display_text || currentTtsJob.mes) : currentTtsJob.mes;
502619
503620 // Substitute macros
@@ -553,18 +670,31 @@ async function processTtsQueue() {
553670 return;
554671 }
555672
556- const voiceMapEntry = voiceMap[char] === DEFAULT_VOICE_MARKER ? voiceMap[DEFAULT_VOICE_MARKER] : voiceMap[char];
673+ // Parse message into segments if multi-voice is enabled
674+ const segments = parseMessageSegments(text);
557675
558676 if (!voiceMapEntry || voiceMapEntrysegments.length === DISABLED_VOICE_MARKER0) {
559- throw `${char} not in voicemap. Configure character in extension settings voice map`;
677+ console.warn('No valid segments found in text.');
678+ completeTtsJob();
679+ return;
560680 }
561- const voice = await ttsProvider.getVoice(voiceMapEntry);
681+
562- const voiceId = voice.voice_id;
682+ // Add all segments to the queue as separate jobs (in reverse order so they process in correct order)
563- if (voiceId == null) {
683+ for (let i = segments.length - 1; i >= 0; i--) {
564- toastr.error(`Specified voice for ${char} was not found. Check the TTS extension settings.`);
684+ const segmentJob = {
565- throw `Unable to attain voiceId for ${char}`;
685+ name: char,
686+ segmentType: segments[i].type,
687+ segmentText: segments[i].text,
688+ is_user: currentTtsJob.is_user,
689+ mes: currentTtsJob.mes,
690+ extra: currentTtsJob.extra,
691+ };
692+ ttsJobQueue.unshift(segmentJob);
566693 }
567- await tts(text, voiceId, char);
694+
695+ // Clear current job so the segmented jobs can be processed
696+ currentTtsJob = null;
697+
568698 } catch (error) {
569699 toastr.error(error.toString());
570700 console.error(error);
@@ -619,6 +749,7 @@ function loadSettings() {
619749 $('#tts_pass_asterisks').prop('checked', extension_settings.tts.pass_asterisks);
620750 $('#tts_skip_codeblocks').prop('checked', extension_settings.tts.skip_codeblocks);
621751 $('#tts_skip_tags').prop('checked', extension_settings.tts.skip_tags);
752+ $('#tts_multi_voice_enabled').prop('checked', extension_settings.tts.multi_voice_enabled);
622753 $('#playback_rate').val(extension_settings.tts.playback_rate);
623754 $('#playback_rate_counter').val(Number(extension_settings.tts.playback_rate).toFixed(2));
624755 $('#playback_rate_block').toggle(extension_settings.tts.currentProvider !== 'System');
@@ -633,6 +764,7 @@ const defaultSettings = {
633764 auto_generation: true,
634765 narrate_user: false,
635766 playback_rate: 1,
767+ multi_voice_enabled: false,
636768};
637769
638770function setTtsStatus(status, success) {
@@ -726,6 +858,13 @@ function onPassAsterisksClick() {
726858 console.log('setting pass asterisks', extension_settings.tts.pass_asterisks);
727859}
728860
861+function onMultiVoiceClick() {
862+ extension_settings.tts.multi_voice_enabled = !!$('#tts_multi_voice_enabled').prop('checked');
863+ saveSettingsDebounced();
864+ // Reinitialize voice map to show/hide voices
865+ initVoiceMap();
866+}
867+
729868//##############//
730869// TTS Provider //
731870//##############//
@@ -1004,7 +1143,25 @@ function getCharacters(unrestricted) {
10041143 }
10051144 }
10061145 }
10071146 returncharacters = characters.filter(onlyUnique);
1147+
1148+ // If multi-voice is enabled, expand characters to include segment types
1149+ if (extension_settings.tts.multi_voice_enabled) {
1150+ const expandedCharacters = [];
1151+ for (const char of characters) {
1152+ if (char === DEFAULT_VOICE_MARKER || char === 'SillyTavern System') {
1153+ expandedCharacters.push(char);
1154+ } else {
1155+ expandedCharacters.push(`${char} ("Quotes")`);
1156+ expandedCharacters.push(`${char} (*Text inside asterisks*)`);
1157+ expandedCharacters.push(`${char} (Other text)`);
1158+ }
1159+ }
1160+ return expandedCharacters;
1161+ }
1162+
1163+ return characters;
1164+
10081165}
10091166
10101167export function sanitizeId(input) {
@@ -1216,6 +1373,7 @@ jQuery(async function () {
12161373 $('#tts_periodic_auto_generation').on('click', onPeriodicAutoGenerationClick);
12171374 $('#tts_narrate_by_paragraphs').on('click', onNarrateByParagraphsClick);
12181375 $('#tts_narrate_user').on('click', onNarrateUserClick);
1376+ $('#tts_multi_voice_enabled').on('click', onMultiVoiceClick);
12191377
12201378 $('#playback_rate').on('input', function () {
12211379 const value = $(this).val();
public/scripts/extensions/tts/settings.html+10 -2
@@ -56,8 +56,16 @@
5656 <small data-i18n="Skip tagged blocks">Skip &lt;tagged&gt; blocks</small>
5757 </label>
5858 <label class="checkbox_label" for="tts_pass_asterisks">
5959 <input type="checkbox" id="tts_pass_asterisks">
6060 <small data-i18n="Pass Asterisks to TTS Engine">Pass Asterisks to TTS Engine</small>
61+ </label>
62+ <label class="checkbox_label" for="tts_multi_voice_enabled"
63+ data-i18n="[title]Works best when: Pass Asterisks to TTS Engine is enabled, and both Only narrate quotes and Ignore *text, even 'quotes', inside asterisks* are disabled."
64+ title="Works best when: Pass Asterisks to TTS Engine is enabled, and both Only narrate quotes and Ignore *text, even 'quotes', inside asterisks* are disabled.">
65+ <input type="checkbox" id="tts_multi_voice_enabled">
66+ <small data-i18n="Different voices for quotes and text inside asterisks">
67+ Different voices for "quotes", *text inside asterisks* and other text
68+ </small>
6169 </label>
6270 </div>
6371 <div id="playback_rate_block" class="range-block">