Implement robust quote extraction for TTS narration with support for nested quotes (#4502) * feat: implement robust quote extraction for TTS narration with multi-language support * refactor: simplify quote matching definition, remove dead code

862c2b7ba3da1295b0547ae125e8960393452b87

Wolfsblvt <wolfsblvt@gmail.com>

Signed
1 files changed, +77 -4Showing whitespace changes
public/scripts/extensions/tts/index.js+77 -4
@@ -637,11 +637,8 @@ async function processTtsQueue() {
637 }637 }
638638
639 if (extension_settings.tts.narrate_quoted_only) {639 if (extension_settings.tts.narrate_quoted_only) {
640 const special_quotes = /[“”«»「」『』""]/g; // Extend this regex to include other special quotes
641 text = text.replace(special_quotes, '"');
642 const matches = text.match(/".*?"/g); // Matches text inside double quotes, non-greedily
643 const partJoiner = (ttsProvider?.separator || ' ... ');640 const partJoiner = (ttsProvider?.separator || ' ... ');
644 text = matches ? matches.join(partJoiner) : text;641 text = joinQuotedBlocks(text, { separator: partJoiner, includeQuotes: true });
645 }642 }
646643
647 // Remove embedded images644 // Remove embedded images
@@ -702,6 +699,82 @@ async function processTtsQueue() {
702 }699 }
703}700}
704701
702/**
703 * Extract and join quoted blocks with proper matching pairs and nesting.
704 * - Captures outermost quotes and everything inside (including different inner quote styles).
705 * - Requires matching opener/closer style (e.g., “ ... ”, 「 ... 」, « ... », etc.).
706 * - Ignores incomplete/unclosed quotes (doesn't include them in the result).
707 * - Symmetric quotes like "..." and "..." are supported (not nesting the same symmetric style).
708 *
709 * @param {string} text - The text to process
710 * @param {object} [opts={}] - Optional options object
711 * @param {string} [opts.separator=' ... '] - String to join multiple quoted blocks
712 * @param {boolean} [opts.includeQuotes=true] - Keep the quote chars around the captured text
713 * @param {boolean} [opts.returnEmptyOnNoQuotes=false] - Return an empty string if no quotes are found
714 * @param {Array<[string,string]>} [opts.pairs] - Custom quote pairs; defaults cover EN/DE/FR/JP
715 * @returns {string} The joined quoted blocks, or the original text if no quotes found
716 */
717function joinQuotedBlocks(text, opts = {}) {
718 const {
719 separator = ' ... ',
720 includeQuotes = true,
721 returnEmptyOnNoQuotes = false,
722 pairs = [
723 // typographic doubles
724 ['„', '“'], // DE low-high
725 ['“', '”'], // EN
726 ['«', '»'], // FR open « close »
727 ['»', '«'], // Some locales open »
728 // typographic singles
729 ['‘', '’'],
730 ['‚', '‘'],
731 // Japanese corner quotes
732 ['「', '」'],
733 ['『', '』'],
734 // symmetric doubles
735 ['"', '"'],
736 ['"', '"'],
737 ],
738 } = opts;
739
740 if (!text || typeof text !== 'string') return text;
741
742 const openToClose = Object.fromEntries(pairs);
743
744 const segments = [];
745 const stack = []; // [{ opener, expectedClose, start }]
746 for (let i = 0; i < text.length; i++) {
747 const ch = text[i];
748 const top = stack[stack.length - 1];
749
750 // Prefer closing the current open pair if the char matches its expected closer
751 if (top && ch === top.expectedClose) {
752 const finished = stack.pop();
753 if (stack.length === 0) {
754 // Only collect outermost quotes (contains all nested content)
755 segments.push(text.slice(finished.start, i + 1));
756 }
757 continue;
758 }
759
760 // Otherwise, see if this is a new opener
761 if (openToClose[ch]) {
762 stack.push({ opener: ch, expectedClose: openToClose[ch], start: i });
763 continue;
764 }
765
766 // If it's a stray closer that doesn't match current top, ignore
767 }
768
769 if (!segments.length) return returnEmptyOnNoQuotes ? '' : text;
770
771 const cleaned = includeQuotes
772 ? segments
773 : segments.map(s => s.slice(1, -1)); // all defined pairs are single-char quotes
774
775 return cleaned.join(separator);
776}
777
705async function playFullConversation() {778async function playFullConversation() {
706 resetTtsPlayback();779 resetTtsPlayback();
707780