Show how a word is pronounced, Classical Persian first

Wiktionary carries pronunciation for 45,000 of these headwords and the build
was throwing it away. It is now kept: 101,306 entries of IPA, each tagged with
the variety it belongs to, which matters here because a word in a 14th-century
ghazal was not said the way Tehran says it now — عشق is /ˈʔiʃq/ in Classical
Persian and [ʔeʃɢ̥] in Iran today, and the app leads with the former.

The IPA's dots and stress marks are the syllable breakdown. Urdu Wiktionary
adds its own, in Urdu script: the fully vowelled spelling عِشْق and the split
عِش + قوں, both pulled out of its wikitext.

Costs 7.7 MB of database, taking it to 94 MB.

Verified on an API 36 emulator: tapping عشق shows the Classical Persian IPA,
the Urdu syllable split, the vowelled spelling and two modern variants above
the definitions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anas RashidandClaude Opus 5 committed 2026-10-04 17:48:12 +02:00
1 parent b16d020ce0
commit 0df736fd89
5 files changed
+92 -4

No files matched your search

Binary file not shown.
@@ -11,13 +11,19 @@ import java.text.Normalizer
/** One definition, and where it came from, so the credit stays attached to the text. */
data class Definition(val word: String, val gloss: String, val source: String)
/**
* How a word sounds. [label] is the variety it belongs to — Classical Persian, Dari, Standard
* Urdu — because a word in a 14th-century ghazal was not said the way Tehran says it now.
*/
data class Pronunciation(val text: String, val label: String)
/**
* Word lookup over a bundled SQLite built from Wiktionary (CC BY-SA 3.0) and Daneshjoo.
* See `tools/build_dictionary.py`; the asset ships gzipped and is unpacked once on first use.
*/
object Dictionary {
/** Guards against a copy interrupted half-way leaving an unopenable file behind. */
private const val ASSET_BYTES = 86663168L
private const val ASSET_BYTES = 94384128L
// Not a .gz: the build packager silently gunzips those and drops the extension, which left
// the asset under a different name than the code was opening.
@@ -46,6 +52,37 @@ object Dictionary {
}
}
/**
* How [raw] is pronounced, Classical Persian first: this is an app for poetry written long
* before modern Tehrani vowels, and the IPA's dots and stress marks are the syllable
* breakdown that goes with it.
*/
suspend fun pronunciations(raw: String, limit: Int = 5): List<Pronunciation> =
withContext(Dispatchers.IO) {
val database = open() ?: return@withContext emptyList()
val word = normalise(raw).takeIf { it.isNotEmpty() } ?: return@withContext emptyList()
database.rawQuery(
"""
SELECT text, label FROM pron WHERE word = ?
ORDER BY CASE
WHEN label LIKE 'Classical%' THEN 0
WHEN source = 'urwiktionary' THEN 1
WHEN source = 'wiktionary-fa' THEN 2
WHEN source = 'wiktionary-ur' THEN 3
ELSE 4
END
LIMIT ?
""",
arrayOf(word, limit.toString()),
).use { cursor ->
buildList {
while (cursor.moveToNext()) {
add(Pronunciation(cursor.getString(0), cursor.getString(1)))
}
}
}
}
/**
* Looks a word up, widening the search until something matches:
*
@@ -30,6 +30,7 @@ import androidx.compose.ui.unit.dp
import com.ganjoor.android.R
import com.ganjoor.android.data.Definition
import com.ganjoor.android.data.Dictionary
import com.ganjoor.android.data.Pronunciation
import com.ganjoor.android.ui.theme.readingStyle
/** English prose inside an otherwise right-to-left sheet. */
@@ -69,6 +70,9 @@ fun WordSheet(word: String, onDismiss: () -> Unit) {
val definitions by produceState<List<Definition>?>(null, current) {
value = Dictionary.lookup(current)
}
val sounds by produceState(emptyList<Pronunciation>(), current) {
value = Dictionary.pronunciations(current)
}
val suggestions by produceState(emptyList<String>(), current, definitions) {
value = if (definitions?.isEmpty() == true) Dictionary.suggest(current) else emptyList()
}
@@ -88,6 +92,25 @@ fun WordSheet(word: String, onDismiss: () -> Unit) {
style = readingStyle(prefs.font, prefs.fontSize, prefs.fontWeight.weight),
modifier = Modifier.fillMaxWidth(),
)
if (sounds.isNotEmpty()) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
sounds.forEach { sound ->
Column {
// IPA is Latin-script, so it reads left to right whatever the UI does
LeftToRight {
Text(sound.text, style = MaterialTheme.typography.bodyMedium)
}
if (sound.label.isNotBlank()) {
Text(
text = sound.label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
HorizontalDivider()
when {