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 }