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() {
467 currentTtsJob = null;467 currentTtsJob = null;
468}468}
469469
470async function tts(text, voiceId, char) {470async function tts(text, voiceId, char, voiceMapKey = null) {
471 async function processResponse(response) {471 async function processResponse(response) {
472 // RVC injection472 // RVC injection
473 if (typeof window['rvcVoiceConversion'] === 'function' && extension_settings.rvc.enabled)473 if (typeof window['rvcVoiceConversion'] === 'function' && extension_settings.rvc.enabled)
@@ -476,7 +476,8 @@ async function tts(text, voiceId, char) {
476 await addAudioJob(response, char);476 await addAudioJob(response, char);
477 }477 }
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
481 // If async generator, process every chunk as it comes in482 // If async generator, process every chunk as it comes in
482 if (typeof response[Symbol.asyncIterator] === 'function') {483 if (typeof response[Symbol.asyncIterator] === 'function') {
@@ -490,6 +491,69 @@ async function tts(text, voiceId, char) {
490 completeTtsJob();491 completeTtsJob();
491}492}
492493
494function 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
493async function processTtsQueue() {557async function processTtsQueue() {
494 // Called each moduleWorker iteration to pull chat messages from queue558 // Called each moduleWorker iteration to pull chat messages from queue
495 if (currentTtsJob || ttsJobQueue.length <= 0 || audioPaused) {559 if (currentTtsJob || ttsJobQueue.length <= 0 || audioPaused) {
@@ -498,6 +562,59 @@ async function processTtsQueue() {
498562
499 console.debug('New message found, running TTS');563 console.debug('New message found, running TTS');
500 currentTtsJob = ttsJobQueue.shift();564 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)
501 let text = extension_settings.tts.narrate_translated_only ? (currentTtsJob?.extra?.display_text || currentTtsJob.mes) : currentTtsJob.mes;618 let text = extension_settings.tts.narrate_translated_only ? (currentTtsJob?.extra?.display_text || currentTtsJob.mes) : currentTtsJob.mes;
502619
503 // Substitute macros620 // Substitute macros
@@ -553,18 +670,31 @@ async function processTtsQueue() {
553 return;670 return;
554 }671 }
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
558 if (!voiceMapEntry || voiceMapEntry === DISABLED_VOICE_MARKER) {676 if (segments.length === 0) {
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;
560 }680 }
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);
566 }693 }
567 await tts(text, voiceId, char);694
695 // Clear current job so the segmented jobs can be processed
696 currentTtsJob = null;
697
568 } catch (error) {698 } catch (error) {
569 toastr.error(error.toString());699 toastr.error(error.toString());
570 console.error(error);700 console.error(error);
@@ -619,6 +749,7 @@ function loadSettings() {
619 $('#tts_pass_asterisks').prop('checked', extension_settings.tts.pass_asterisks);749 $('#tts_pass_asterisks').prop('checked', extension_settings.tts.pass_asterisks);
620 $('#tts_skip_codeblocks').prop('checked', extension_settings.tts.skip_codeblocks);750 $('#tts_skip_codeblocks').prop('checked', extension_settings.tts.skip_codeblocks);
621 $('#tts_skip_tags').prop('checked', extension_settings.tts.skip_tags);751 $('#tts_skip_tags').prop('checked', extension_settings.tts.skip_tags);
752 $('#tts_multi_voice_enabled').prop('checked', extension_settings.tts.multi_voice_enabled);
622 $('#playback_rate').val(extension_settings.tts.playback_rate);753 $('#playback_rate').val(extension_settings.tts.playback_rate);
623 $('#playback_rate_counter').val(Number(extension_settings.tts.playback_rate).toFixed(2));754 $('#playback_rate_counter').val(Number(extension_settings.tts.playback_rate).toFixed(2));
624 $('#playback_rate_block').toggle(extension_settings.tts.currentProvider !== 'System');755 $('#playback_rate_block').toggle(extension_settings.tts.currentProvider !== 'System');
@@ -633,6 +764,7 @@ const defaultSettings = {
633 auto_generation: true,764 auto_generation: true,
634 narrate_user: false,765 narrate_user: false,
635 playback_rate: 1,766 playback_rate: 1,
767 multi_voice_enabled: false,
636};768};
637769
638function setTtsStatus(status, success) {770function setTtsStatus(status, success) {
@@ -726,6 +858,13 @@ function onPassAsterisksClick() {
726 console.log('setting pass asterisks', extension_settings.tts.pass_asterisks);858 console.log('setting pass asterisks', extension_settings.tts.pass_asterisks);
727}859}
728860
861function 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
729//##############//868//##############//
730// TTS Provider //869// TTS Provider //
731//##############//870//##############//
@@ -1004,7 +1143,25 @@ function getCharacters(unrestricted) {
1004 }1143 }
1005 }1144 }
1006 }1145 }
1007 return characters.filter(onlyUnique);1146 characters = 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
1008}1165}
10091166
1010export function sanitizeId(input) {1167export function sanitizeId(input) {
@@ -1216,6 +1373,7 @@ jQuery(async function () {
1216 $('#tts_periodic_auto_generation').on('click', onPeriodicAutoGenerationClick);1373 $('#tts_periodic_auto_generation').on('click', onPeriodicAutoGenerationClick);
1217 $('#tts_narrate_by_paragraphs').on('click', onNarrateByParagraphsClick);1374 $('#tts_narrate_by_paragraphs').on('click', onNarrateByParagraphsClick);
1218 $('#tts_narrate_user').on('click', onNarrateUserClick);1375 $('#tts_narrate_user').on('click', onNarrateUserClick);
1376 $('#tts_multi_voice_enabled').on('click', onMultiVoiceClick);
12191377
1220 $('#playback_rate').on('input', function () {1378 $('#playback_rate').on('input', function () {
1221 const value = $(this).val();1379 const value = $(this).val();
public/scripts/extensions/tts/settings.html+10 -2
@@ -56,8 +56,16 @@
56 <small data-i18n="Skip tagged blocks">Skip &lt;tagged&gt; blocks</small>56 <small data-i18n="Skip tagged blocks">Skip &lt;tagged&gt; blocks</small>
57 </label>57 </label>
58 <label class="checkbox_label" for="tts_pass_asterisks">58 <label class="checkbox_label" for="tts_pass_asterisks">
59 <input type="checkbox" id="tts_pass_asterisks">59 <input type="checkbox" id="tts_pass_asterisks">
60 <small data-i18n="Pass Asterisks to TTS Engine">Pass Asterisks to TTS Engine</small>60 <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>
61 </label>69 </label>
62 </div>70 </div>
63 <div id="playback_rate_block" class="range-block">71 <div id="playback_rate_block" class="range-block">