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/CloudTest.kt (7560 bytes)

1 package com.honjimaku.subrep
2 
3 import com.sun.net.httpserver.HttpExchange
4 import com.sun.net.httpserver.HttpServer
5 import io.kotest.property.Arb
6 import io.kotest.property.arbitrary.float
7 import io.kotest.property.arbitrary.list
8 import io.kotest.property.checkAll
9 import kotlinx.coroutines.runBlocking
10 import okhttp3.OkHttpClient
11 import org.junit.After
12 import org.junit.Assert.assertArrayEquals
13 import org.junit.Assert.assertEquals
14 import org.junit.Assert.assertNull
15 import org.junit.Assert.assertTrue
16 import org.junit.Assert.fail
17 import org.junit.Before
18 import org.junit.Test
19 import java.net.InetSocketAddress
20 import java.util.concurrent.CopyOnWriteArrayList
21 import kotlin.math.abs
22 import kotlin.random.Random
23 
24 /** The cloud client against a local server that answers as subread.space does. */
25 class CloudTest {
26 
27     private class Seen(val method: String, val query: String?, val cookie: String?, val body: ByteArray)
28 
29     private lateinit var server: HttpServer
30     private val seen = CopyOnWriteArrayList<Seen>()
31     private var answer: (HttpExchange) -> Pair<Int, String> = { 200 to "{}" }
32     private var device = ""
33 
34     private fun cloud() = Cloud(OkHttpClient(), { device }, { device = it }, "http://127.0.0.1:${server.address.port}")
35 
36     @Before
37     fun start() {
38         server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
39         server.createContext("/") { exchange ->
40             val body = exchange.requestBody.readBytes()
41             seen += Seen(exchange.requestMethod, exchange.requestURI.rawQuery, exchange.requestHeaders.getFirst("Cookie"), body)
42             if (exchange.requestHeaders.getFirst("Cookie") == null) {
43                 exchange.responseHeaders.add("Set-Cookie", "subplz_device=dev123; HttpOnly; Path=/; SameSite=lax")
44             }
45             val (code, text) = answer(exchange)
46             val bytes = text.toByteArray()
47             exchange.sendResponseHeaders(code, bytes.size.toLong())
48             exchange.responseBody.use { it.write(bytes) }
49         }
50         server.start()
51     }
52 
53     @After
54     fun stop() = server.stop(0)
55 
56     @Test
57     fun theFirstAnswerMakesTheAccountAndEachLaterCallSendsIt() {
58         answer = { 200 to STATE }
59         val cloud = cloud()
60         val state = cloud.state()
61         assertEquals("dev123", device)
62         assertNull(seen[0].cookie)
63         cloud.state()
64         assertEquals("subplz_device=dev123", seen[1].cookie)
65         assertEquals(Cloud.State("acct_1", 7200, true, listOf(Cloud.Pack("captions20", "20 hours of cloud captions", 20, "$4.99"))), state)
66     }
67 
68     @Test
69     fun aPieceGoesAsPcmWithItsLanguage() {
70         device = "dev123"
71         answer = { 200 to """{"text": "こんにちは", "seconds": 3, "seconds_left": 57}""" }
72         val pcm = byteArrayOf(1, 2, 3, 4)
73         val result = cloud().transcribe(pcm, "ja")
74         assertEquals(Cloud.Answer("こんにちは", 57), result)
75         assertEquals("POST", seen[0].method)
76         assertEquals("lang=ja", seen[0].query)
77         assertArrayEquals(pcm, seen[0].body)
78     }
79 
80     @Test
81     fun noHoursLeftIsOutOfHours() {
82         device = "dev123"
83         answer = { 402 to """{"detail": "No cloud caption hours left."}""" }
84         try {
85             cloud().transcribe(ByteArray(2), "ja")
86             fail("no exception")
87         } catch (_: Cloud.OutOfHours) {
88         }
89     }
90 
91     @Test
92     fun aServerErrorLosesOnlyThePiece() {
93         device = "dev123"
94         answer = { 502 to """{"detail": "speech service answered 500"}""" }
95         try {
96             cloud().transcribe(ByteArray(2), "ja")
97             fail("no exception")
98         } catch (e: Cloud.PieceFailed) {
99             assertEquals("Cloud: speech service answered 500.", e.message)
100         }
101     }
102 
103     @Test
104     fun noServerLosesOnlyThePiece() {
105         val cloud = cloud()
106         server.stop(0)
107         try {
108             cloud.transcribe(ByteArray(2), "ja")
109             fail("no exception")
110         } catch (_: Cloud.PieceFailed) {
111         }
112     }
113 
114     @Test
115     fun checkoutGivesThePaymentPage() {
116         answer = { 200 to """{"url": "https://checkout.stripe.com/c/pay/cs_1"}""" }
117         assertEquals("https://checkout.stripe.com/c/pay/cs_1", cloud().checkout("captions20"))
118         assertEquals("""{"pack_id":"captions20"}""", String(seen[0].body))
119     }
120 
121     @Test
122     fun theDeviceComesOnlyFromItsOwnCookie() {
123         assertEquals("abc", Cloud.deviceFrom(listOf("other=1; Path=/", "subplz_device=abc; HttpOnly")))
124         assertNull(Cloud.deviceFrom(listOf("subplz_device=; Max-Age=0")))
125         assertNull(Cloud.deviceFrom(emptyList()))
126     }
127 
128     @Test
129     fun hoursReadAsHoursAndMinutes() {
130         assertEquals("0 min", Cloud.hours(59))
131         assertEquals("59 min", Cloud.hours(3599))
132         assertEquals("20 h 0 min", Cloud.hours(72000))
133         assertEquals("1 h 1 min", Cloud.hours(3660))
134     }
135 
136     /** What goes to the cloud comes back as the same sound, within one step of 16-bit PCM. */
137     @Test
138     fun pcmOfFloatsReadsBackTheSameSound(): Unit = runBlocking {
139         checkAll(Arb.list(Arb.float(-1f, 1f), 0..400)) { list ->
140             val samples = list.toFloatArray()
141             val bytes = Pcm.toBytes(samples)
142             val back = FloatArray(samples.size)
143             assertEquals(samples.size, Pcm.toFloat(bytes, bytes.size, back))
144             for (i in samples.indices) assertTrue("${samples[i]} -> ${back[i]}", abs(samples[i] - back[i]) <= 1f / 32768f)
145         }
146     }
147 
148     @Test
149     fun loudSoundIsClippedNotWrapped() {
150         val back = FloatArray(2)
151         Pcm.toFloat(Pcm.toBytes(floatArrayOf(1.5f, -1.5f)), 4, back)
152         assertEquals(32767f / 32768f, back[0])
153         assertEquals(-1f, back[1])
154     }
155 
156     /** The engine stops asking the cloud once the hours are gone. */
157     @Test
158     fun theEngineStopsWhenTheHoursAreGone() {
159         val calls = CopyOnWriteArrayList<Int>()
160         val states = CopyOnWriteArrayList<String>()
161         val captions = CopyOnWriteArrayList<String>()
162         val recognizer = object : Recognizer {
163             override val detectedLanguage = ""
164             override fun transcribe(samples: FloatArray, language: String): String {
165                 calls += samples.size
166                 if (calls.size > 1) throw Cloud.OutOfHours()
167                 return "一つ目"
168             }
169             override fun close() = Unit
170         }
171         val engine = Engine({ recognizer }, "ja", { captions += it.text }, {}, { states += it })
172         engine.start()
173         val random = Random(7)
174         repeat(3) { feed(engine, speech(1_500, random) + FloatArray(16_000)) }
175         engine.stop()
176         assertEquals(listOf("一つ目"), captions)
177         assertEquals(2, calls.size)
178         assertTrue(states.toString(), states.last().startsWith(Engine.STATE_ERROR + "No cloud hours left"))
179     }
180 
181     private fun speech(ms: Int, random: Random) = FloatArray(16 * ms) { random.nextFloat() * 0.6f - 0.3f }
182 
183     /** Feeds [sound] in frames of 100 ms, and waits until the worker took each piece. */
184     private fun feed(engine: Engine, sound: FloatArray) {
185         val bytes = Pcm.toBytes(sound)
186         var at = 0
187         while (at < bytes.size) {
188             val n = minOf(3_200, bytes.size - at)
189             engine.feed(bytes.copyOfRange(at, at + n), n)
190             at += n
191         }
192         Thread.sleep(300)
193     }
194 
195     private companion object {
196         const val STATE = """{"account_id": "acct_1", "seconds_left": 7200, "available": true,
197             "packs": [{"id": "captions20", "name": "20 hours of cloud captions", "hours": 20, "price_display": "$4.99"}]}"""
198     }
199 }