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


app/src/test/kotlin/com/honjimaku/subrep/SegmenterTest.kt (4108 bytes)

1 package com.honjimaku.subrep
2 
3 import io.kotest.property.Arb
4 import io.kotest.property.arbitrary.float
5 import io.kotest.property.arbitrary.int
6 import io.kotest.property.arbitrary.list
7 import io.kotest.property.checkAll
8 import kotlinx.coroutines.runBlocking
9 import org.junit.Assert.assertEquals
10 import org.junit.Assert.assertNull
11 import org.junit.Assert.assertTrue
12 import org.junit.Test
13 import kotlin.random.Random
14 
15 class SegmenterTest {
16 
17     private val rate = 16_000
18 
19     private fun silence(ms: Int) = FloatArray(rate * ms / 1000)
20 
21     /** Loud noise, as speech would be: well above the gate. */
22     private fun speech(ms: Int, random: Random) = FloatArray(rate * ms / 1000) { random.nextFloat() * 0.6f - 0.3f }
23 
24     /** Feeds [sound] in frames of 100 ms, as the capture does, and returns the pieces. */
25     private fun pieces(segmenter: Segmenter, sound: FloatArray): List<FloatArray> {
26         val out = ArrayList<FloatArray>()
27         val frame = rate / 10
28         var at = 0
29         while (at < sound.size) {
30             val n = minOf(frame, sound.size - at)
31             segmenter.feed(sound.copyOfRange(at, at + n), n, out)
32             at += n
33         }
34         return out
35     }
36 
37     @Test
38     fun silenceGivesNoPiece(): Unit = runBlocking {
39         checkAll(Arb.int(0..30_000), Arb.float(0f, 0.003f)) { ms, amplitude ->
40             val random = Random(ms)
41             val quiet = FloatArray(rate * ms / 1000) { random.nextFloat() * 2 * amplitude - amplitude }
42             val segmenter = Segmenter()
43             assertTrue(pieces(segmenter, quiet).isEmpty())
44             assertNull(segmenter.flush())
45         }
46     }
47 
48     /** One burst of speech between silences is one piece: the speech, a little before, and the pause after. */
49     @Test
50     fun oneBurstOfSpeechIsOnePiece(): Unit = runBlocking {
51         checkAll(Arb.int(500..9_000)) { ms ->
52             val random = Random(ms)
53             val sound = silence(1_000) + speech(ms, random) + silence(2_000)
54             val out = pieces(Segmenter(), sound)
55             assertEquals("pieces for $ms ms", 1, out.size)
56             val samples = out[0].size
57             // The piece has the speech, up to 320 ms before it, the pause of 650 ms after it, and block rounding.
58             assertTrue("$samples samples for $ms ms", samples >= rate * ms / 1000)
59             assertTrue("$samples samples for $ms ms", samples <= rate * (ms + 320 + 650 + 2 * 32) / 1000)
60         }
61     }
62 
63     @Test
64     fun aShortNoiseIsDropped() {
65         val random = Random(1)
66         val sound = silence(1_000) + speech(200, random) + silence(2_000)
67         assertTrue(pieces(Segmenter(), sound).isEmpty())
68     }
69 
70     /** Speech that goes on is cut at the maximum, and each cut piece is kept. */
71     @Test
72     fun noPieceIsLongerThanTheMaximum(): Unit = runBlocking {
73         checkAll(Arb.list(Arb.int(100..15_000), 1..6)) { lengths ->
74             val random = Random(lengths.hashCode())
75             var sound = silence(500)
76             for ((i, ms) in lengths.withIndex()) sound += if (i % 2 == 0) speech(ms, random) else silence(ms)
77             sound += silence(1_500)
78             val out = pieces(Segmenter(), sound)
79             for (piece in out) assertTrue("${piece.size} samples", piece.size <= rate * 11 + rate * 32 / 1000)
80             // A burst under 450 ms is dropped by design. A cut at the maximum can leave a rest
81             // under 450 ms behind too, so each piece may cost up to half a second of speech.
82             val spoken = lengths.filterIndexed { i, ms -> i % 2 == 0 && ms >= 600 }.sum()
83             val kept = out.sumOf { it.size }
84             assertTrue("kept $kept samples of $spoken ms of speech", kept >= rate * spoken / 1000 - (out.size + 1) * rate / 2)
85         }
86     }
87 
88     @Test
89     fun theOpenPieceComesOutAtTheEnd() {
90         val random = Random(2)
91         val segmenter = Segmenter()
92         val out = pieces(segmenter, silence(500) + speech(1_500, random))
93         assertTrue(out.isEmpty())
94         val last = segmenter.flush()
95         assertTrue(last != null && last.size >= rate * 1_500 / 1000)
96         assertNull(segmenter.flush())
97     }
98 }