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 | 467 | currentTtsJob = null; |
| 468 | 468 | } |
| 469 | 469 | |
| 470 | 470 | async function tts(text, voiceId, char, voiceMapKey = null) { |
| 471 | 471 | async function processResponse(response) { |
| 472 | 472 | // RVC injection |
| 473 | 473 | if (typeof window['rvcVoiceConversion'] === 'function' && extension_settings.rvc.enabled) |
| @@ -476,7 +476,8 @@ async function tts(text, voiceId, char) { | ||
| 476 | 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 | 482 | // If async generator, process every chunk as it comes in |
| 482 | 483 | if (typeof response[Symbol.asyncIterator] === 'function') { |
| @@ -490,6 +491,69 @@ async function tts(text, voiceId, char) { | ||
| 490 | 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 | 557 | async function processTtsQueue() { |
| 494 | 558 | // Called each moduleWorker iteration to pull chat messages from queue |
| 495 | 559 | if (currentTtsJob || ttsJobQueue.length <= 0 || audioPaused) { |
| @@ -498,6 +562,59 @@ async function processTtsQueue() { | ||
| 498 | 562 | |
| 499 | 563 | console.debug('New message found, running TTS'); |
| 500 | 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 | 618 | let text = extension_settings.tts.narrate_translated_only ? (currentTtsJob?.extra?.display_text || currentTtsJob.mes) : currentTtsJob.mes; |
| 502 | 619 | |
| 503 | 620 | // Substitute macros |
| @@ -553,18 +670,31 @@ async function processTtsQueue() { | ||
| 553 | 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 | 676 | 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; | |
| 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 | 698 | } catch (error) { |
| 569 | 699 | toastr.error(error.toString()); |
| 570 | 700 | console.error(error); |
| @@ -619,6 +749,7 @@ function loadSettings() { | ||
| 619 | 749 | $('#tts_pass_asterisks').prop('checked', extension_settings.tts.pass_asterisks); |
| 620 | 750 | $('#tts_skip_codeblocks').prop('checked', extension_settings.tts.skip_codeblocks); |
| 621 | 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 | 753 | $('#playback_rate').val(extension_settings.tts.playback_rate); |
| 623 | 754 | $('#playback_rate_counter').val(Number(extension_settings.tts.playback_rate).toFixed(2)); |
| 624 | 755 | $('#playback_rate_block').toggle(extension_settings.tts.currentProvider !== 'System'); |
| @@ -633,6 +764,7 @@ const defaultSettings = { | ||
| 633 | 764 | auto_generation: true, |
| 634 | 765 | narrate_user: false, |
| 635 | 766 | playback_rate: 1, |
| 767 | + multi_voice_enabled: false, | |
| 636 | 768 | }; |
| 637 | 769 | |
| 638 | 770 | function setTtsStatus(status, success) { |
| @@ -726,6 +858,13 @@ function onPassAsterisksClick() { | ||
| 726 | 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 | 869 | // TTS Provider // |
| 731 | 870 | //##############// |
| @@ -1004,7 +1143,25 @@ function getCharacters(unrestricted) { | ||
| 1004 | 1143 | } |
| 1005 | 1144 | } |
| 1006 | 1145 | } |
| 1007 | 1146 | 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 | + | |
| 1008 | 1165 | } |
| 1009 | 1166 | |
| 1010 | 1167 | export function sanitizeId(input) { |
| @@ -1216,6 +1373,7 @@ jQuery(async function () { | ||
| 1216 | 1373 | $('#tts_periodic_auto_generation').on('click', onPeriodicAutoGenerationClick); |
| 1217 | 1374 | $('#tts_narrate_by_paragraphs').on('click', onNarrateByParagraphsClick); |
| 1218 | 1375 | $('#tts_narrate_user').on('click', onNarrateUserClick); |
| 1376 | + $('#tts_multi_voice_enabled').on('click', onMultiVoiceClick); | |
| 1219 | 1377 | |
| 1220 | 1378 | $('#playback_rate').on('input', function () { |
| 1221 | 1379 | const value = $(this).val(); |
| @@ -59,6 +59,14 @@ | ||
| 59 | 59 | <input type="checkbox" id="tts_pass_asterisks"> |
| 60 | 60 | <small data-i18n="Pass Asterisks to TTS Engine">Pass Asterisks to TTS Engine</small> |
| 61 | 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> | |
| 69 | + </label> | |
| 62 | 70 | </div> |
| 63 | 71 | <div id="playback_rate_block" class="range-block"> |
| 64 | 72 | <hr> |