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>
Signed| @@ -467,7 +467,7 @@ function completeTtsJob() { | |||
| 467 | currentTtsJob = null; | 467 | currentTtsJob = null; |
| 468 | } | 468 | } |
| 469 | 469 | ||
| 470 | async function tts(text, voiceId, char) { | 470 | async function tts(text, voiceId, char, voiceMapKey = null) { |
| 471 | async function processResponse(response) { | 471 | async function processResponse(response) { |
| 472 | // RVC injection | 472 | // 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 | } |
| 478 | 478 | ||
| 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); | ||
| 480 | 481 | ||
| 481 | // If async generator, process every chunk as it comes in | 482 | // 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 | } |
| 492 | 493 | ||
| 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 | |||
| 493 | async function processTtsQueue() { | 557 | async function processTtsQueue() { |
| 494 | // Called each moduleWorker iteration to pull chat messages from queue | 558 | // 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() { | |||
| 498 | 562 | ||
| 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; |
| 502 | 619 | ||
| 503 | // Substitute macros | 620 | // Substitute macros |
| @@ -553,18 +670,31 @@ async function processTtsQueue() { | |||
| 553 | return; | 670 | return; |
| 554 | } | 671 | } |
| 555 | 672 | ||
| 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); | ||
| 557 | 675 | ||
| 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 | }; |
| 637 | 769 | ||
| 638 | function setTtsStatus(status, success) { | 770 | function 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 | } |
| 728 | 860 | ||
| 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 | |||
| 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 | } |
| 1009 | 1166 | ||
| 1010 | export function sanitizeId(input) { | 1167 | export 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); | ||
| 1219 | 1377 | ||
| 1220 | $('#playback_rate').on('input', function () { | 1378 | $('#playback_rate').on('input', function () { |
| 1221 | const value = $(this).val(); | 1379 | const value = $(this).val(); |
| @@ -56,8 +56,16 @@ | |||
| 56 | <small data-i18n="Skip tagged blocks">Skip <tagged> blocks</small> | 56 | <small data-i18n="Skip tagged blocks">Skip <tagged> 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"> |