Recently Written · git

subrep-android

Subrep on Android: live captions of the sound of the phone, made on the phone by Whisper, shared by link.

git clone https://github.com/equwal/subrep-android

Log | Files | Refs


commit 91b529ead58e8db7e7ff1b8f2cd138182767de6c
equwal <truex@equwal.com>
2026-09-21 23:29:36 -0700

Cut a loop of a phrase in a caption

The loop cut only saw a piece of up to 6 characters. Whisper base looped
on a Japanese phrase of 9 characters, live on Android 11, and the caption
kept six repeats of it. The cut now sees a piece of up to 40 characters.
A short piece keeps three repeats. A phrase keeps one. The cut runs until
nothing changes, so a loop that a cut uncovers is cut too.

The test holds the caption that showed the defect.

 .../kotlin/com/honjimaku/subrep/Hallucinations.kt  | 28 ++++++++++++++++++----
 .../com/honjimaku/subrep/HallucinationsTest.kt     | 10 ++++++++
 2 files changed, 33 insertions(+), 5 deletions(-)
diff --git a/app/src/main/kotlin/com/honjimaku/subrep/Hallucinations.kt b/app/src/main/kotlin/com/honjimaku/subrep/Hallucinations.kt
index 4528141..836463b 100644
--- a/app/src/main/kotlin/com/honjimaku/subrep/Hallucinations.kt
+++ b/app/src/main/kotlin/com/honjimaku/subrep/Hallucinations.kt
@@ -36,8 +36,11 @@ object Hallucinations {
         "^[\\p{P}\\p{S}\\p{Z}\\s]*$",
     ).map { Regex(it, RegexOption.IGNORE_CASE) }
 
-    /** A short piece repeated many times over: "о-о-о-о-о-о", "はいはいはいはいはい". */
-    private val loop = Regex("(.{1,6}?)\\1{4,}")
+    /**
+     * A piece repeated four times or more: "о-о-о-о-о-о", "はいはいはいはいはい", or a phrase
+     * of up to [LOOP_PIECE_MAX] characters. The lazy group finds the shortest piece.
+     */
+    private val loop = Regex("(.{1,$LOOP_PIECE_MAX}?)\\1{4,}")
 
     /** True when [text] is one of the invented lines, or has no word at all. */
     fun isHallucination(text: String): Boolean {
@@ -46,8 +49,23 @@ object Hallucinations {
     }
 
     /**
-     * [text] with each loop cut to three of its piece. Whisper falls into a loop on singing or
-     * noise. Three repeats keep a real "no, no, no"; the rest is noise.
+     * [text] with each loop cut. Whisper falls into a loop on singing or noise. A short piece
+     * (up to [SHORT_PIECE_MAX] characters) keeps three repeats, so that a real "no, no, no" stays.
+     * A phrase keeps one. The cut runs again until nothing changes, so a loop that a cut uncovers
+     * is cut too.
      */
-    fun collapse(text: String): String = loop.replace(text) { it.groupValues[1].repeat(3) }
+    fun collapse(text: String): String {
+        var line = text
+        while (true) {
+            val cut = loop.replace(line) { match ->
+                val piece = match.groupValues[1]
+                if (piece.length <= SHORT_PIECE_MAX) piece.repeat(3) else piece
+            }
+            if (cut == line) return line
+            line = cut
+        }
+    }
+
+    private const val LOOP_PIECE_MAX = 40
+    private const val SHORT_PIECE_MAX = 6
 }
diff --git a/app/src/test/kotlin/com/honjimaku/subrep/HallucinationsTest.kt b/app/src/test/kotlin/com/honjimaku/subrep/HallucinationsTest.kt
index a39ade2..3dfd92c 100644
--- a/app/src/test/kotlin/com/honjimaku/subrep/HallucinationsTest.kt
+++ b/app/src/test/kotlin/com/honjimaku/subrep/HallucinationsTest.kt
@@ -20,6 +20,16 @@ class HallucinationsTest {
         assertEquals("Все голоса у всех певцов одинаково мерзкие.", Hallucinations.collapse("Все голоса у всех певцов одинаково мерзкие."))
     }
 
+    /** A loop of a long piece (a phrase) is cut to one of its piece. Seen live on Android 11 with `base`, Japanese. */
+    @Test
+    fun aLoopOfAPhraseIsCutToOneOfItsPiece() {
+        assertEquals(
+            "もうそこからはそこからは意識はうねり混ざり合うあかりと暗がりのうぞの中に暗が",
+            Hallucinations.collapse("もうそこからはそこからは意識はうねり混ざり合うあかりと暗がりのうぞの中に暗がりのうぞの中に暗がりのうぞの中に暗がりのうぞの中に暗がりのうぞの中に暗がりのうぞの中に暗が"),
+        )
+        assertEquals("I don't know, so", Hallucinations.collapse("I don't know, I don't know, I don't know, I don't know, I don't know, so"))
+    }
+
     /** A collapse leaves a line without a loop as it is, and a second collapse changes nothing. */
     @Test
     fun aCollapseNeverGrowsAndIsDoneOnce(): Unit = runBlocking {