← Whispering

fix: per-sentence error recovery and improved text sanitization for Kokoro

82b940490df2b71db4fd2f0d6b3f2ef80b163d2f · Claude · Feb 10, 2026 21:41

Two changes to make Kokoro TTS robust against arbitrary input text:

1. Improved _sanitize_text(): fix double periods ("Paris..\n" → "Paris.")
   that occurred when period-ending text was followed by newlines that got
   converted to ". ". Also collapse repeated punctuation more broadly.

2. Per-sentence error recovery: split text into sentences and feed each
   individually to KPipeline with split_pattern=None. If the G2P crashes
   on one sentence (TypeError from None phonemes), log a warning and skip
   it rather than failing the entire synthesis.

https://claude.ai/code/session_01965m1v3f1cK6gDV1JHioA8

+25 −10 · 1 file changed

  • src/tts_provider.py +25 −10
diff --git a/src/tts_provider.py b/src/tts_provider.py
index 4b7c4af..100e4cd 100644
--- a/src/tts_provider.py
+++ b/src/tts_provider.py
@@ -660,18 +660,18 @@ class KokoroTTSProvider(BaseTTSProvider):
         We flatten the most common problematic patterns into plain prose
         that the G2P can process safely.
         """
-        # Numbered list markers: "1. Foo" → "First, Foo"  (only for #1)
-        #                        "2. Foo" → "Foo"
+        # Numbered list markers: "1. Foo" / "1) Foo" → "Foo"
         text = re.sub(r'(?m)^\s*\d+[\.\)]\s*', '', text)
         # Bullet markers: "- Foo" / "* Foo" / "• Foo"
         text = re.sub(r'(?m)^\s*[-*•]\s+', '', text)
-        # Collapse multiple newlines into a single period-space
-        # (Kokoro splits on \n+ so each fragment must be a valid sentence)
-        text = re.sub(r'\n{2,}', '. ', text)
-        # Single newlines → space (within a paragraph)
-        text = re.sub(r'\n', ' ', text)
         # Standalone dashes/hyphens used as separators: " - " → ", "
         text = re.sub(r'\s+[-–—]\s+', ', ', text)
+        # Collapse multiple newlines into a single space
+        text = re.sub(r'\n+', ' ', text)
+        # Repeated punctuation: ".." / "!!" / "..." → single
+        text = re.sub(r'([.!?])[.!?\s]+(?=[A-Z\s])', r'\1 ', text)
+        # Stray colons / semicolons before period: ":." → "."
+        text = re.sub(r'[:;]\s*\.', '.', text)
         # Collapse runs of whitespace
         text = re.sub(r'  +', ' ', text)
         return text.strip()
@@ -779,10 +779,25 @@ class KokoroTTSProvider(BaseTTSProvider):
         tts_log(f"Kokoro synthesize (voice={voice}): "
                 f"{len(text)} chars \"{text[:60]}...\"")
 
+        # Split into sentences ourselves and feed each to the pipeline
+        # individually.  This way a single bad segment (where the G2P
+        # returns None phonemes) doesn't kill the entire synthesis.
+        sentences = [s.strip() for s in re.split(r'(?<=[.!?])\s+', text)
+                     if s.strip()]
+        if not sentences:
+            sentences = [text]
+
         audio_segments = []
-        for gs, ps, audio in self._pipeline(text, voice=voice):
-            if audio is not None and len(audio) > 0:
-                audio_segments.append(audio)
+        for sentence in sentences:
+            try:
+                for gs, ps, audio in self._pipeline(
+                        sentence, voice=voice, split_pattern=None):
+                    if audio is not None and len(audio) > 0:
+                        audio_segments.append(audio)
+            except (TypeError, ValueError, RuntimeError) as e:
+                tts_log(f"Kokoro G2P warning: skipping segment "
+                        f"({e}): \"{sentence[:40]}...\"")
+                continue
 
         if not audio_segments:
             raise RuntimeError("Kokoro produced no audio output")