← All source files

V5 / f52bca255516

KokoroTtsSanitizer.cs

Lilith/Core/Kokoro/KokoroTtsSanitizer.cs

Changed in this version
CS
using System.Globalization;
using System.Text;

namespace Lilith.Core.Kokoro;

/// <summary>Conservative speech cleanup; leaves pronunciation of numbers/abbreviations to the phonemizer.</summary>
public static class KokoroTtsSanitizer
{
    public static string Sanitize(string text)
    {
        ArgumentNullException.ThrowIfNull(text);
        // Remove fenced code before interpreting its contents as prose, including an unclosed fence.
        text = KokoroRegex.CodeFence().Replace(text, " ");
        text = KokoroRegex.MarkdownLink().Replace(text, "${label}");
        text = KokoroRegex.WebUrl().Replace(text, match =>
        {
            // Discard the address but retain punctuation ending the surrounding sentence.
            string url = match.Value.TrimEnd(')', ']', '}', '"', '\'');
            int end = url.Length;
            while (end > 0 && ".!?;:".Contains(url[end - 1])) end--;
            return " " + url[end..];
        });
        for (int pass = 0; pass < 3; pass++) text = KokoroRegex.Emphasis().Replace(text, "${value}");
        text = KokoroRegex.StrikeThrough().Replace(text, "${value}").Replace("`", "");

        // Misaki can split surrogate pairs before normalization; spoken input excludes pictographs.
        // Grapheme rendering and the original chat/history text remain untouched.
        var spoken = new StringBuilder(text.Length);
        foreach (var rune in text.EnumerateRunes())
        {
            if (rune.Value is 0x200B or 0x2060 or 0xFEFF or 0x00AD) continue;
            bool unsupported = rune.Utf16SequenceLength == 2 || Rune.GetUnicodeCategory(rune) == UnicodeCategory.OtherSymbol ||
                rune.Value is 0x200D or 0xFE0E or 0xFE0F or 0xFFFD ||
                (Rune.GetUnicodeCategory(rune) == UnicodeCategory.Control && rune.Value is not (9 or 10 or 13));
            spoken.Append(unsupported ? " " : rune.ToString());
        }
        text = spoken.ToString().Normalize(NormalizationForm.FormC).Replace("\r\n", "\n").Replace('\r', '\n');
        var result = new StringBuilder(text.Length);
        bool paragraphBreak = false, previousList = false;
        foreach (string raw in text.Split('\n'))
        {
            string line = raw.Trim();
            if (line.Length == 0) { paragraphBreak = result.Length > 0; continue; }
            bool listItem = KokoroRegex.ListMarker().IsMatch(line);
            bool heading = KokoroRegex.HeadingMarker().IsMatch(line);
            line = KokoroRegex.ListMarker().Replace(line, "");
            line = KokoroRegex.HeadingMarker().Replace(line, "");
            line = KokoroRegex.QuoteMarker().Replace(line, "");
            line = KokoroRegex.Whitespace().Replace(line, " ").Trim();
            if (line.Length == 0) continue;
            if (result.Length > 0)
            {
                // Wrapped prose is joined without new pauses; structural boundaries retain a sentence break.
                int lastIndex = result.Length - 1;
                while (lastIndex > 0 && " )]\"'".Contains(result[lastIndex])) lastIndex--;
                char last = result[lastIndex];
                if ((paragraphBreak || listItem || heading || (previousList && !char.IsWhiteSpace(raw[0]))) && !".!?;:…".Contains(last)) result.Append('.');
                result.Append(' ');
            }
            result.Append(line);
            paragraphBreak = false;
            previousList = listItem || (previousList && char.IsWhiteSpace(raw[0]));
        }
        text = KokoroRegex.Whitespace().Replace(result.ToString(), " ").Trim();
        text = KokoroRegex.RepeatedPunctuation().Replace(text, "$1");
        return KokoroRegex.SpaceBeforePunctuation().Replace(text, "$1");
    }
}