V5 / f52bca255516
KokoroTtsSanitizer.cs
Lilith/Core/Kokoro/KokoroTtsSanitizer.cs
Changed in this versionCS
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");
}
}