support sidecar and embedded lyrics

This commit is contained in:
Boof2015
2026-07-15 15:02:49 -04:00
parent b5f1ca9e99
commit 92128b0b99
13 changed files with 825 additions and 35 deletions
@@ -22,4 +22,5 @@ dependencies {
// MetadataRetriever — parses ID3/Vorbis/MP4 container tags (ReplayGain) without
// decoding audio. Transitively brings exoplayer-extractor (the frame classes).
implementation 'com.google.android.exoplayer:exoplayer-core:2.19.0'
testImplementation 'junit:junit:4.13.2'
}
@@ -14,6 +14,7 @@ import android.os.Build
import android.provider.DocumentsContract
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MetadataRetriever
import com.google.android.exoplayer2.metadata.id3.BinaryFrame
import com.google.android.exoplayer2.metadata.id3.InternalFrame
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
import com.google.android.exoplayer2.metadata.flac.VorbisComment
@@ -132,10 +133,9 @@ class AstraLibraryScannerModule : Module() {
withContext(Dispatchers.IO) { readSidecarLyrics(uri) }
}
// Embedded lyrics from container tags (Vorbis LYRICS/UNSYNCEDLYRICS for
// FLAC/Ogg/Opus, plus TXXX/MP4 lyric atoms), metadata-only (no PCM decode).
// Returns { text } or null. ID3 USLT/SYLT are not decoded by ExoPlayer and are
// intentionally out of scope here.
// Embedded lyrics from container tags (Vorbis comments, ID3 USLT/SYLT/TXXX,
// and MP4 lyric atoms), metadata-only (no PCM decode). Distinguishing a true
// miss from an I/O failure lets JS invalidate only genuinely stale cache rows.
AsyncFunction("readEmbeddedLyrics") Coroutine { uri: String ->
withContext(Dispatchers.IO) { readEmbeddedLyrics(uri) }
}
@@ -341,26 +341,23 @@ class AstraLibraryScannerModule : Module() {
/**
* Read embedded lyrics from container metadata via ExoPlayer's MetadataRetriever.
* Covers Vorbis LYRICS/UNSYNCEDLYRICS (FLAC/Ogg/Opus), TXXX:LYRICS (ID3), and MP4
* freeform lyric atoms — metadata only, no PCM decode. ID3 USLT/SYLT arrive as
* undecoded BinaryFrames and are out of scope. Returns { text } or null.
* ExoPlayer decodes Vorbis/TXXX/MP4 text entries directly and exposes ID3 lyric
* frames as BinaryFrame payloads, which Id3LyricsParser handles without reading
* or decoding PCM.
*/
private fun readEmbeddedLyrics(uriStr: String): Map<String, Any?>? {
private fun readEmbeddedLyrics(uriStr: String): Map<String, Any?> {
return try {
val mediaItem = MediaItem.fromUri(Uri.parse(uriStr))
val trackGroups = MetadataRetriever.retrieveMetadata(requireContext(), mediaItem)
.get(metadataTimeoutMs, TimeUnit.MILLISECONDS)
// Keep the longest lyric candidate (a full body beats a stray short tag).
var best: String? = null
val collector = EmbeddedLyricsCollector()
fun consider(rawKey: String?, rawValue: String?) {
if (rawKey == null || rawValue == null) return
val key = rawKey.trim().lowercase()
val isLyricKey = key in embeddedLyricKeys || key.contains("lyric") || key == "©lyr"
if (!isLyricKey) return
val value = rawValue.trim()
if (value.isEmpty()) return
if (best == null || value.length > best!!.length) best = value
collector.considerPlain(rawValue)
}
for (g in 0 until trackGroups.length) {
@@ -369,17 +366,31 @@ class AstraLibraryScannerModule : Module() {
val metadata = group.getFormat(f).metadata ?: continue
for (i in 0 until metadata.length()) {
when (val entry = metadata.get(i)) {
is TextInformationFrame -> if (entry.id == "TXXX") consider(entry.description, entry.value)
is TextInformationFrame -> when (entry.id) {
"TXXX" -> consider(entry.description, entry.value)
// ExoPlayer maps the standard MP4/M4A ©lyr atom to USLT text.
"USLT", "ULT" -> collector.considerPlain(entry.value)
else -> Unit
}
is VorbisComment -> consider(entry.key, entry.value)
is InternalFrame -> consider(entry.description, entry.text)
is BinaryFrame -> collector.consider(Id3LyricsParser.parse(entry.id, entry.data))
}
}
}
}
best?.let { mapOf("text" to it) }
val lyrics = collector.valueOrNull()
?: return mapOf("status" to "missing")
mapOf(
"status" to "hit",
"text" to lyrics.text,
"syncText" to lyrics.syncText.map { entry ->
mapOf("timestampMs" to entry.timestampMs, "text" to entry.text)
},
)
} catch (_: Throwable) {
null
mapOf("status" to "unavailable")
}
}
@@ -0,0 +1,223 @@
package expo.modules.astralibraryscanner
import java.nio.ByteBuffer
import java.nio.charset.Charset
import java.nio.charset.CodingErrorAction
import java.nio.charset.StandardCharsets
internal data class SyncedLyricText(
val timestampMs: Long,
val text: String,
)
internal sealed interface Id3LyricsCandidate {
data class Plain(val text: String) : Id3LyricsCandidate
data class Synced(val entries: List<SyncedLyricText>) : Id3LyricsCandidate
}
internal data class EmbeddedLyricsValue(
val text: String?,
val syncText: List<SyncedLyricText>,
)
/**
* Collects lyric candidates from every metadata entry in a container. A valid
* synchronized frame wins; otherwise the longest plain body wins. This keeps a
* stray short tag from displacing a full lyric body while remaining deterministic.
*/
internal class EmbeddedLyricsCollector {
private var bestPlain: String? = null
private var bestSynced: List<SyncedLyricText>? = null
fun considerPlain(rawText: String?) {
val text = normalizePlainText(rawText) ?: return
if (bestPlain == null || text.length > bestPlain!!.length) bestPlain = text
}
fun consider(candidate: Id3LyricsCandidate?) {
when (candidate) {
is Id3LyricsCandidate.Plain -> considerPlain(candidate.text)
is Id3LyricsCandidate.Synced -> considerSynced(candidate.entries)
null -> Unit
}
}
private fun considerSynced(rawEntries: List<SyncedLyricText>) {
val entries = rawEntries.filter { it.text.isNotBlank() }
if (entries.isEmpty()) return
val current = bestSynced
if (
current == null ||
entries.size > current.size ||
(entries.size == current.size && entries.sumOf { it.text.length } > current.sumOf { it.text.length })
) {
bestSynced = entries
}
}
fun valueOrNull(): EmbeddedLyricsValue? {
val synced = bestSynced
if (synced != null) {
val text = normalizePlainText(synced.joinToString(separator = "") { it.text })
return EmbeddedLyricsValue(text = text, syncText = synced)
}
return bestPlain?.let { EmbeddedLyricsValue(text = it, syncText = emptyList()) }
}
}
/** Decoder for the payload bytes ExoPlayer exposes in ID3 BinaryFrame entries. */
internal object Id3LyricsParser {
private const val TIMESTAMP_FORMAT_MILLISECONDS = 2
private val acceptedSyncedContentTypes = setOf(1, 2) // lyrics, text transcription
fun parse(frameId: String, data: ByteArray): Id3LyricsCandidate? = when (frameId.uppercase()) {
"USLT", "ULT" -> parseUnsynchronized(data)
"SYLT", "SLT" -> parseSynchronized(data)
else -> null
}
private fun parseUnsynchronized(data: ByteArray): Id3LyricsCandidate.Plain? {
if (data.size < 5) return null
val encoding = data[0].toInt() and 0xff
if (!isSupportedEncoding(encoding)) return null
// Encoding byte + ISO-639-2 language. The content descriptor is not part of
// the displayed lyrics, but its BOM can establish UTF-16 byte order.
val descriptor = readTerminated(data, 4, encoding) ?: return null
val lyrics = decodeRange(data, descriptor.nextIndex, data.size, encoding, descriptor.utf16Charset)
?: return null
val normalized = normalizePlainText(lyrics) ?: return null
return Id3LyricsCandidate.Plain(normalized)
}
private fun parseSynchronized(data: ByteArray): Id3LyricsCandidate.Synced? {
if (data.size < 7) return null
val encoding = data[0].toInt() and 0xff
if (!isSupportedEncoding(encoding)) return null
val timestampFormat = data[4].toInt() and 0xff
val contentType = data[5].toInt() and 0xff
if (timestampFormat != TIMESTAMP_FORMAT_MILLISECONDS) return null
if (contentType !in acceptedSyncedContentTypes) return null
val descriptor = readTerminated(data, 6, encoding) ?: return null
var offset = descriptor.nextIndex
val entries = mutableListOf<SyncedLyricText>()
while (offset < data.size) {
val decoded = readTerminated(data, offset, encoding, descriptor.utf16Charset) ?: return null
offset = decoded.nextIndex
if (offset + 4 > data.size) return null
val timestamp =
((data[offset].toLong() and 0xff) shl 24) or
((data[offset + 1].toLong() and 0xff) shl 16) or
((data[offset + 2].toLong() and 0xff) shl 8) or
(data[offset + 3].toLong() and 0xff)
offset += 4
val text = normalizeSyncedText(decoded.text)
if (text.isNotBlank()) entries += SyncedLyricText(timestampMs = timestamp, text = text)
}
return entries.takeIf { it.isNotEmpty() }?.let { Id3LyricsCandidate.Synced(it) }
}
private data class DecodedTerminatedString(
val text: String,
val nextIndex: Int,
val utf16Charset: Charset?,
)
private fun readTerminated(
data: ByteArray,
start: Int,
encoding: Int,
inheritedUtf16Charset: Charset? = null,
): DecodedTerminatedString? {
if (start !in 0..data.size) return null
val terminatorLength = if (encoding == 1 || encoding == 2) 2 else 1
val end = findTerminator(data, start, terminatorLength) ?: return null
val charset = resolveUtf16Charset(data, start, end, encoding, inheritedUtf16Charset)
val text = decodeRange(data, start, end, encoding, charset) ?: return null
return DecodedTerminatedString(text, end + terminatorLength, charset)
}
private fun findTerminator(data: ByteArray, start: Int, terminatorLength: Int): Int? {
if (terminatorLength == 1) {
for (index in start until data.size) if (data[index].toInt() == 0) return index
return null
}
var index = start
while (index + 1 < data.size) {
if (data[index].toInt() == 0 && data[index + 1].toInt() == 0) return index
index += 2
}
return null
}
private fun resolveUtf16Charset(
data: ByteArray,
start: Int,
end: Int,
encoding: Int,
inherited: Charset?,
): Charset? {
if (encoding == 2) return StandardCharsets.UTF_16BE
if (encoding != 1) return null
if (end - start >= 2) {
val first = data[start].toInt() and 0xff
val second = data[start + 1].toInt() and 0xff
if (first == 0xfe && second == 0xff) return StandardCharsets.UTF_16BE
if (first == 0xff && second == 0xfe) return StandardCharsets.UTF_16LE
}
return inherited ?: StandardCharsets.UTF_16BE
}
private fun decodeRange(
data: ByteArray,
start: Int,
end: Int,
encoding: Int,
utf16Charset: Charset? = null,
): String? {
if (start < 0 || end < start || end > data.size) return null
val charset = when (encoding) {
0 -> StandardCharsets.ISO_8859_1
1 -> utf16Charset ?: resolveUtf16Charset(data, start, end, encoding, null) ?: return null
2 -> StandardCharsets.UTF_16BE
3 -> StandardCharsets.UTF_8
else -> return null
}
var contentStart = start
if (encoding == 1 && end - start >= 2) {
val first = data[start].toInt() and 0xff
val second = data[start + 1].toInt() and 0xff
if ((first == 0xfe && second == 0xff) || (first == 0xff && second == 0xfe)) {
contentStart += 2
}
}
val contentLength = end - contentStart
if ((encoding == 1 || encoding == 2) && contentLength % 2 != 0) return null
return try {
charset.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(data, contentStart, contentLength))
.toString()
} catch (_: Throwable) {
null
}
}
private fun isSupportedEncoding(encoding: Int): Boolean = encoding in 0..3
}
private fun normalizePlainText(value: String?): String? {
if (value == null) return null
val normalized = value.replace("\r\n", "\n").replace('\r', '\n').trim().trim('\u0000')
return normalized.takeIf { it.isNotEmpty() }
}
private fun normalizeSyncedText(value: String): String =
value.replace("\r\n", "\n").replace('\r', '\n').trim('\u0000')
@@ -0,0 +1,163 @@
package expo.modules.astralibraryscanner
import java.io.ByteArrayOutputStream
import java.nio.charset.StandardCharsets
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class Id3LyricsParserTest {
@Test
fun `USLT decodes every ID3 text encoding`() {
val samples = listOf(
0 to "Café line",
1 to "日本語の歌詞",
2 to "Ελληνικοί στίχοι",
3 to "한글 가사",
)
for ((encoding, expected) in samples) {
val candidate = Id3LyricsParser.parse("USLT", uslt(encoding, expected))
assertEquals(expected, (candidate as Id3LyricsCandidate.Plain).text)
}
}
@Test
fun `ULT alias decodes multiline plain lyrics`() {
val candidate = Id3LyricsParser.parse("ULT", uslt(3, "First\r\nSecond"))
assertEquals("First\nSecond", (candidate as Id3LyricsCandidate.Plain).text)
}
@Test
fun `SYLT and SLT decode millisecond synchronized entries`() {
for (frameId in listOf("SYLT", "SLT")) {
val candidate = Id3LyricsParser.parse(
frameId,
sylt(
encoding = 3,
timestampFormat = 2,
contentType = 1,
entries = listOf(1_000L to "First", 2_500L to " Second"),
)
) as Id3LyricsCandidate.Synced
assertEquals(
listOf(
SyncedLyricText(1_000, "First"),
SyncedLyricText(2_500, " Second"),
),
candidate.entries,
)
}
}
@Test
fun `SYLT accepts text transcription but rejects unrelated content`() {
assertTrue(
Id3LyricsParser.parse(
"SYLT",
sylt(0, 2, 2, listOf(500L to "Transcript")),
) is Id3LyricsCandidate.Synced
)
assertNull(
Id3LyricsParser.parse(
"SYLT",
sylt(0, 2, 3, listOf(500L to "Movement")),
)
)
}
@Test
fun `SYLT rejects MPEG-frame timestamps`() {
assertNull(
Id3LyricsParser.parse(
"SYLT",
sylt(3, 1, 1, listOf(42L to "Unsupported units")),
)
)
}
@Test
fun `malformed and unsupported payloads fail closed`() {
assertNull(Id3LyricsParser.parse("USLT", byteArrayOf(3, 'e'.code.toByte())))
assertNull(Id3LyricsParser.parse("USLT", byteArrayOf(9, 'e'.code.toByte(), 'n'.code.toByte(), 'g'.code.toByte(), 0)))
assertNull(Id3LyricsParser.parse("USLT", byteArrayOf(3, 'e'.code.toByte(), 'n'.code.toByte(), 'g'.code.toByte(), 0, 0xc3.toByte(), 0x28)))
assertNull(Id3LyricsParser.parse("USLT", byteArrayOf(2, 'e'.code.toByte(), 'n'.code.toByte(), 'g'.code.toByte(), 0, 0, 0x41)))
assertNull(Id3LyricsParser.parse("SYLT", sylt(3, 2, 1, listOf(1_000L to "Line")).dropLast(2).toByteArray()))
assertNull(Id3LyricsParser.parse("COMM", uslt(3, "Not lyrics")))
}
@Test
fun `collector prefers the most complete synchronized candidate`() {
val collector = EmbeddedLyricsCollector()
collector.considerPlain("A much longer plain lyric body that should not win")
collector.consider(
Id3LyricsCandidate.Synced(listOf(SyncedLyricText(1_000, "One")))
)
collector.consider(
Id3LyricsCandidate.Synced(
listOf(
SyncedLyricText(1_000, "First "),
SyncedLyricText(2_000, "second"),
)
)
)
val result = collector.valueOrNull()!!
assertEquals("First second", result.text)
assertEquals(2, result.syncText.size)
}
@Test
fun `collector chooses the longest plain candidate when no sync exists`() {
val collector = EmbeddedLyricsCollector()
collector.considerPlain("Short")
collector.considerPlain("The complete lyric body")
val result = collector.valueOrNull()!!
assertEquals("The complete lyric body", result.text)
assertTrue(result.syncText.isEmpty())
}
private fun uslt(encoding: Int, text: String): ByteArray = ByteArrayOutputStream().use { output ->
output.write(encoding)
output.write("eng".toByteArray(StandardCharsets.ISO_8859_1))
output.write(terminated(encoding, "description"))
output.write(encoded(encoding, text))
output.toByteArray()
}
private fun sylt(
encoding: Int,
timestampFormat: Int,
contentType: Int,
entries: List<Pair<Long, String>>,
): ByteArray = ByteArrayOutputStream().use { output ->
output.write(encoding)
output.write("eng".toByteArray(StandardCharsets.ISO_8859_1))
output.write(timestampFormat)
output.write(contentType)
output.write(terminated(encoding, ""))
for ((timestamp, text) in entries) {
output.write(terminated(encoding, text))
output.write(((timestamp ushr 24) and 0xff).toInt())
output.write(((timestamp ushr 16) and 0xff).toInt())
output.write(((timestamp ushr 8) and 0xff).toInt())
output.write((timestamp and 0xff).toInt())
}
output.toByteArray()
}
private fun terminated(encoding: Int, text: String): ByteArray =
encoded(encoding, text) + if (encoding == 1 || encoding == 2) byteArrayOf(0, 0) else byteArrayOf(0)
private fun encoded(encoding: Int, text: String): ByteArray = when (encoding) {
0 -> text.toByteArray(StandardCharsets.ISO_8859_1)
1 -> byteArrayOf(0xff.toByte(), 0xfe.toByte()) + text.toByteArray(StandardCharsets.UTF_16LE)
2 -> text.toByteArray(StandardCharsets.UTF_16BE)
3 -> text.toByteArray(StandardCharsets.UTF_8)
else -> text.toByteArray(StandardCharsets.UTF_8)
}
}