Recently Written · git

subread-dictionary

Pop-up dictionary for Android that reads Yomitan dictionaries, with local audio

git clone https://github.com/equwal/subread-dictionary

Log | Files | Refs


app/src/main/kotlin/space/subread/dictionary/Audio.kt (4934 bytes)

1 package space.subread.dictionary
2 
3 import android.content.Context
4 import android.database.sqlite.SQLiteDatabase
5 import android.media.MediaPlayer
6 import com.google.gson.JsonParser
7 import java.io.File
8 import java.io.FileInputStream
9 import java.net.HttpURLConnection
10 import java.net.URL
11 import java.net.URLEncoder
12 
13 /** One place that can have the audio of a term. The bytes are loaded when the user plays it. */
14 class AudioSource(val name: String, val load: () -> ByteArray?)
15 
16 /**
17  * The android.db of the Local Audio Server for Yomitan. Two tables: `entries` gives the file of
18  * a term for each source, `android` holds the bytes of each file.
19  */
20 class LocalAudio(private val file: File, private val sourceOrder: List<String>) {
21 
22     fun exists() = file.isFile
23 
24     fun sources(expression: String, reading: String): List<AudioSource> {
25         if (!file.isFile) return emptyList()
26         val db = runCatching { SQLiteDatabase.openDatabase(file.path, null, SQLiteDatabase.OPEN_READONLY) }.getOrNull() ?: return emptyList()
27         val kana = reading.ifEmpty { expression }
28         data class Row(val source: String, val speaker: String?, val display: String?, val file: String)
29         val rows = db.rawQuery(
30             "SELECT source, speaker, display, file FROM entries WHERE expression = ? AND (reading = ? OR reading IS NULL)",
31             arrayOf(expression, kana),
32         ).use { c ->
33             buildList { while (c.moveToNext()) add(Row(c.getString(0), c.getString(1), c.getString(2), c.getString(3))) }
34         }
35         val ordered = rows.sortedBy { sourceOrder.indexOf(it.source).let { i -> if (i < 0) sourceOrder.size else i } }
36         return ordered.map { row ->
37             // Forvo puts the speaker in both columns: show it once.
38             val name = listOfNotNull(label(row.source), row.speaker, row.display).distinct().joinToString(" ")
39             AudioSource(name) {
40                 db.rawQuery("SELECT data FROM android WHERE file = ? AND source = ?", arrayOf(row.file, row.source)).use { c ->
41                     if (c.moveToFirst()) c.getBlob(0) else null
42                 }
43             }
44         }
45     }
46 
47     private fun label(source: String) = when (source) {
48         "jpod" -> "JPod101"
49         "jpod_alternate" -> "JPod101 Alternate"
50         "nhk16" -> "NHK16"
51         "shinmeikai8" -> "Shinmeikai 8"
52         "forvo" -> "Forvo"
53         else -> source
54     }
55 }
56 
57 /** Audio from a URL. A URL can answer with an audio file, or with the JSON source list of a Local Audio Server. */
58 object RemoteAudio {
59 
60     /** The size of the file that JapanesePod101 sends for a word it does not have. Yomitan skips it too. */
61     private const val JPOD_MISSING_SIZE = 52288
62 
63     fun sources(templates: List<String>, expression: String, reading: String): List<AudioSource> = templates.map { template ->
64         val url = template
65             .replace("{term}", URLEncoder.encode(expression, "UTF-8"))
66             .replace("{reading}", URLEncoder.encode(reading.ifEmpty { expression }, "UTF-8"))
67         AudioSource(URL(url).host) { fetchAudio(url) }
68     }
69 
70     private fun fetchAudio(url: String): ByteArray? {
71         val bytes = fetch(url) ?: return null
72         val text = if (bytes.size < 1_000_000) bytes.decodeToString().trimStart() else ""
73         if (text.startsWith("{")) {
74             // The JSON of a Local Audio Server: the first source that has bytes.
75             val list = runCatching { JsonParser.parseString(text).asJsonObject.getAsJsonArray("audioSources") }.getOrNull() ?: return null
76             for (source in list) {
77                 val sourceUrl = source.asJsonObject.get("url")?.asString ?: continue
78                 val audio = fetch(sourceUrl)
79                 if (audio != null && audio.isNotEmpty()) return audio
80             }
81             return null
82         }
83         if (bytes.isEmpty() || bytes.size == JPOD_MISSING_SIZE) return null
84         return bytes
85     }
86 
87     private fun fetch(url: String): ByteArray? = runCatching {
88         val connection = URL(url).openConnection() as HttpURLConnection
89         connection.connectTimeout = 8000
90         connection.readTimeout = 8000
91         try {
92             if (connection.responseCode != 200) null else connection.inputStream.use { it.readBytes() }
93         } finally {
94             connection.disconnect()
95         }
96     }.getOrNull()
97 }
98 
99 /** Plays one audio file. The bytes go to a file in the cache: MediaPlayer wants a file descriptor. */
100 class AudioPlayer(private val context: Context) {
101 
102     private var player: MediaPlayer? = null
103 
104     fun play(bytes: ByteArray) {
105         stop()
106         val file = File(context.cacheDir, "audio.tmp")
107         file.writeBytes(bytes)
108         player = MediaPlayer().apply {
109             FileInputStream(file).use { setDataSource(it.fd) }
110             setOnCompletionListener { stop() }
111             prepare()
112             start()
113         }
114     }
115 
116     fun stop() {
117         player?.release()
118         player = null
119     }
120 }