Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 18 additions & 10 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
## MusicResync v1.6.4
## MusicResync v1.7.0

### New: cover-art identification — the file's own thumbnail now tells us who sings it
A file like **"Bounce"** with artist "Unknown" is textually indistinguishable from dozens of other songs called Bounce — that's how it got Russian lyrics from a completely different track. But its embedded cover art *is* distinguishable. When a file gives the matcher no usable artist at all, MusicResync now searches iTunes/Deezer widely with the bare title, compares each result's album art against the file's own cover, and treats a visual match (plus a compatible runtime) as the file's real identity. For the reported "Bounce", that discovers **Voyage** — whose synced lyrics are right there on LRCLib — and the search leads with "Voyage Bounce" instead of a blind "Bounce".
### Fast mode, fixed and promoted — now the recommended way to batch download
Fast mode was silently searching only your top **two** providers — that's why songs like **"Ili Ili"** and **"Peta Brzina"** (which are on LRCLib, synced) came back "not found" in fast batch runs while the provider that has them never even got asked. Fast mode now races **all** enabled providers in parallel with tight time budgets: same providers, same candidate parsing, same precision guards — the speed comes from not waiting on slow servers, not from skipping sources. The toggle now also lives at the top of the **batch download options**, marked *Recommended*, and applies to batch runs.

The same check now guards against runtime coincidences: a result whose artist agrees with *none* of the file's artist readings (like **"CHECK"** by WAV3POP getting Portuguese lyrics from a same-length "Check" by The Boy) gets a second opinion from the cover before being trusted — if the art identifies a different artist, the impostor is skipped.
### Lyrics player actually follows the song now
The real cause of the "player stuck at the first line" bug: when opening a song from Home, the lyrics load in the background — and the highlight logic kept watching the initial *empty* lyrics list forever, so neither the highlight nor the scroll ever moved. Fixed. Word-by-word files (`<00:40.49>` inline stamps) also no longer show those raw stamps as text — and files carrying *only* word stamps now play instead of showing nothing.

### Messy names that now resolve (verified against the live catalogues)
- **"DEVITO - FLEX ????"** — runs of `?` left by characters the filesystem couldn't store are stripped from queries; the track ("Taj flex" by Devito) is on LRCLib.
- **"CRNI CERAK - CC #2 (JUŽNI VETAR 2022)"** — bracketed junk the noise list doesn't know now falls back to a bare-title query; "CC #2" by Crni Cerak is on LRCLib.
- **"GRCA X FOX - BELE ŠARE"** — *every* collab artist is now tried, not just the first: the track is indexed under Fox (with GRCA as the guest), and Fox no longer counts as a stranger to the wrong-singer veto.
- **"CHECK" by "WAV3POP - Topic"** — YouTube channel decorations (" - Topic", " TV", " Official"…) are stripped into an extra artist reading, so the real artist name reaches the providers.
### The "200" fluke, and the class it belongs to, is closed
"200" by Ourmoney got Russian lyrics from a same-*length* "200" by a different act — a runtime coincidence was still enough to override a disagreeing artist. In batch, it isn't anymore: a match whose artist agrees with **none** of the file's artist readings must now be positively confirmed by the file's cover art, or it's skipped. (The correct "200" exists on LRCLib as unsynced — enable "Add unsynced if no synced" to pick it up.)

Songs genuinely absent from every lyrics catalogue still fail honestly — no fluke lyrics.
### Cyrillic and Latin finally speak the same language
Provider entries written in Cyrillic ("Секси") now compare equal to their Latin spelling ("Seksi") instead of counting as strangers — matching now works across scripts in both directions, for artists and titles.

### More recovered names
- **'Summer Cem - "TMM TMM"'** — quotes in a title no longer break provider search (the song is on LRCLib, synced).
- Channel-artist and collab-splitting fixes from 1.6.4 now actually reach every provider thanks to the fast-mode fix.

### Honest about the rest
We verified the missing Serbian catalogue directly: QQ Music *indexes* many of these songs (Prazan Disko, Volim Pare…) but has **no lyric content** for them, Genius has no usable public API, and tekstovi.net has no machine-readable search. Songs that no catalogue carries still fail honestly — no fluke lyrics, ever.

**Samsung Music note:** Samsung's player only reads *synced* `.lrc` sidecars and *embedded* tags. If you want unsynced lyrics visible there, enable **Embed lyrics in file** in batch options — a plain text `.lrc` alone won't show up.

Install over your existing app — it's signed with the same key, so it upgrades in place.
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ android {
minSdk = 21
//noinspection OldTargetApi
targetSdk = 35
versionCode = 164
versionName = "1.6.4"
versionCode = 170
versionName = "1.7.0"

vectorDrawables {
useSupportLibrary = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ class UserSettingsController(private val dataStore: DataStore<Preferences>) {
private set

var batchAutoTryProviders by mutableStateOf(prefs[batchAutoTryProvidersKey] ?: true)
var batchFastMode by mutableStateOf(prefs[batchFastModeKey] ?: true)
private set

// Fast mode for the single-song search: race only the top providers with a short timeout. Off by default —
Expand Down Expand Up @@ -262,6 +263,11 @@ class UserSettingsController(private val dataStore: DataStore<Preferences>) {
batchAutoTryProviders = to
}

fun updateBatchFastMode(to: Boolean) {
dataStore.set(batchFastModeKey, to)
batchFastMode = to
}

fun updateSingleFastMode(to: Boolean) {
dataStore.set(singleFastModeKey, to)
singleFastMode = to
Expand Down Expand Up @@ -302,4 +308,5 @@ private val batchCorrectMetadataKey = booleanPreferencesKey("batch_correct_metad
private val batchSkipExistingKey = booleanPreferencesKey("batch_skip_existing")
private val batchSkipNoLyricsKey = booleanPreferencesKey("batch_skip_no_lyrics")
private val batchAutoTryProvidersKey = booleanPreferencesKey("batch_auto_try_providers")
private val batchFastModeKey = booleanPreferencesKey("batch_fast_mode")
private val singleFastModeKey = booleanPreferencesKey("single_fast_mode")
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,19 @@ data class MatchConfig(
) {
companion object {
/**
* "Fast mode" for the single-song search: only the top [maxProviders] providers, one retry, a short
* per-provider budget and minimal politeness delay. Trades a little reliability for speed — the regular
* config stays the default for batch runs.
* Fast mode: ALL enabled providers race in parallel (they do anyway) with one retry, a short
* per-provider budget and minimal politeness delay. v1.6.x additionally truncated the provider list
* to 2, which silently skipped LRCLib for some provider orders and cost real matches ("Ili Ili",
* "Peta Brzina" were reported "not found" purely because the provider that has them never ran) — speed
* comes from the caps and the early auto-accept stop, NOT from dropping providers, so reliability for
* indexed songs now equals the regular config. The full candidate ladder is kept: parsing messy names
* is what finds these files at all.
*/
fun fast(providerOrder: List<Providers>, maxProviders: Int = 2) = MatchConfig(
providerOrder = providerOrder.take(maxProviders),
fun fast(providerOrder: List<Providers>) = MatchConfig(
providerOrder = providerOrder,
maxRetries = 1,
requestDelayMs = 50,
maxCandidatesPerProvider = 3,
maxCandidatesPerProvider = 4,
retryBaseDelayMs = 250,
providerTimeoutMs = 8_000,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,14 @@ fun BatchOptionsScreen(

SettingsHeadLabel(label = stringResource(R.string.batch_options_general))

// Fast mode leads the list: same providers, same matching guards, tighter time budgets — the
// recommended default for every batch run.
SwitchItem(
label = stringResource(R.string.fast_mode) + " • " + stringResource(R.string.recommended),
description = stringResource(R.string.batch_fast_mode_desc),
selected = settings.batchFastMode,
) { settings.updateBatchFastMode(!settings.batchFastMode) }

SwitchItem(
label = stringResource(R.string.skip_existing_lyrics),
description = stringResource(R.string.skip_existing_lyrics_desc),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import pl.lambada.songsync.R

private val lrcTag = Regex("""\[[^]]*]""")
// Line tags "[...]" AND enhanced-LRC word stamps "<00:40.49>" — neither belongs in a text preview.
private val lrcTag = Regex("""\[[^]]*]|<\d{1,2}:\d{2}[.:]\d{1,3}>""")

/**
* Result-screen lyric panel. The heavy lifting (play-along preview + offset correction) now lives in the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,27 @@ private const val OFFSET_RANGE_MS = 10000f

private val timestamp = Regex("""\[(\d{1,2}):(\d{2})[.:](\d{1,3})]""")

/** Enhanced-LRC word timestamps ("<00:40.490>word") — line-level display must not show them as text. */
private val wordTimestamp = Regex("""<(\d{1,2}):(\d{2})[.:](\d{1,3})>""")

private fun parseSyncedLrc(raw: String): List<LyricLine> {
val out = ArrayList<LyricLine>()
for (line in raw.lineSequence()) {
val matches = timestamp.findAll(line).toList()
if (matches.isEmpty()) continue
val text = line.substring(matches.last().range.last + 1).trim()
if (matches.isEmpty()) {
// Word-by-word files may carry ONLY inline "<mm:ss.xx>" stamps on a line: use the first as the
// line time so these files still play instead of parsing to nothing.
val word = wordTimestamp.find(line) ?: continue
val (mm, ss, frac) = word.destructured
val ms = mm.toLong() * 60_000 + ss.toLong() * 1_000 + (frac.padEnd(3, '0').take(3)).toLong()
val text = wordTimestamp.replace(line, " ").replace(Regex("""\s+"""), " ").trim()
out.add(LyricLine(ms, text))
continue
}
// Strip inline word stamps from the display text — they belong to the timing model, not the lyrics
// (they were being shown raw: "<00:40.490>I <00:40.666>was ...").
val text = wordTimestamp.replace(line.substring(matches.last().range.last + 1), "")
.replace(Regex("""\s+"""), " ").trim()
for (m in matches) {
val (mm, ss, frac) = m.destructured
val ms = mm.toLong() * 60_000 + ss.toLong() * 1_000 +
Expand Down Expand Up @@ -307,7 +322,10 @@ fun SyncedLyricsPlayerScreen(
val listState = rememberLazyListState()
// derivedStateOf: the lyric list recomposes only when the highlighted line actually changes, NOT on every
// 120ms position poll. That per-poll recomposition of every visible line was the source of the scroll jank.
val currentIndex by remember {
// MUST be keyed on `lines`: when opening from Home the lyrics load asynchronously, and an unkeyed remember
// captured the initial EMPTY list forever — the highlight (and therefore the auto-scroll) stayed frozen on
// the first line for the entire song.
val currentIndex by remember(lines) {
derivedStateOf {
if (lines.isEmpty()) 0
// Lyrics are neutral, so the slider value is the effective absolute offset applied to the preview.
Expand Down
42 changes: 26 additions & 16 deletions app/src/main/java/pl/lambada/songsync/util/LyricsUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,10 @@ suspend fun downloadLyrics(
// The chain is tried strictly in the user's configured order (issue #6) — no adaptive reshuffling, so
// the behaviour always matches what Settings shows.
val providerOrder = if (!autoTryProviders) listOf(configuredProviders.first()) else configuredProviders
val songConfig = MatchConfig(providerOrder = providerOrder)
// Fast mode (recommended): same providers, same candidate ladder, same guards — just tighter retry/
// timeout budgets, so a hung host costs seconds instead of half a minute.
val songConfig = if (settings.batchFastMode) MatchConfig.fast(providerOrder)
else MatchConfig(providerOrder = providerOrder)

// Always overwrite once we've decided to process: replaces a stale/plain .lrc with the synced result.
val info = matchAndSaveSong(song, settings, context, matcher, songConfig, correctMetadata, overwriteExisting = true, saveLrc = saveLrc, embedLyrics = embedLyrics, addUnsyncedFallback = addUnsyncedFallback(), onMetadataCorrected = onMetadataCorrected, onPlainAvailable = onPlainAvailable)
Expand Down Expand Up @@ -479,22 +482,26 @@ suspend fun matchAndSaveSong(
// than give up we try the next hit/provider.
var chosen: ScoredHit? = null
var lyrics: String? = null
// A hit whose artist agrees with none of our guesses survived only on the exact-runtime escape ("200" by
// Tveth landing on Ourmoney's "200", "Check" by The Boy on WAV3POP's "CHECK" — the lengths coincided).
// Batch saves unattended, so a runtime coincidence alone is NOT enough anymore: the file's cover art must
// positively confirm the hit's artist, otherwise the hit is treated as an impostor and skipped. The
// single-song screen keeps the escape (the user sees the result and judges it).
suspend fun confirmedByCover(resultArtist: String?): Boolean {
if (!artistDisagreesWithAllGuesses(artistGuesses, resultArtist)) return true
if (!discoveryRan) {
discoveryRan = true
discovered = runCatching {
CoverIdentity.discover(context, song.filePath, candidates.map { it.asSearchString() }, local.durationSec)
}.getOrNull()
}
val idArtist = discovered?.artist ?: return false
return TextMatch.similarity(idArtist, resultArtist) >= 0.50
}

for (hit in nonRejected) {
coroutineContext.ensureActive()
// A hit whose artist agrees with none of our guesses survived only on the exact-runtime escape
// ("Check" by The Boy landing on WAV3POP's "CHECK" because the lengths coincided). Before trusting
// it, ask the cover art for a second opinion: if the file's own art identifies a DIFFERENT artist,
// the hit is an impostor — skip it. No art / no identification keeps the existing behaviour.
if (artistDisagreesWithAllGuesses(artistGuesses, hit.result.artist)) {
if (!discoveryRan) {
discoveryRan = true
discovered = runCatching {
CoverIdentity.discover(context, song.filePath, candidates.map { it.asSearchString() }, local.durationSec)
}.getOrNull()
}
val idArtist = discovered?.artist
if (idArtist != null && TextMatch.similarity(idArtist, hit.result.artist) < 0.50) continue
}
if (!confirmedByCover(hit.result.artist)) continue
val l = runCatching { matcher.fetchLyrics(hit, config) }.getOrNull()
if (!l.isNullOrBlank()) { chosen = hit; lyrics = l; break }
}
Expand All @@ -510,6 +517,9 @@ suspend fun matchAndSaveSong(
rescued = runCatching {
matcher.lastResort(local, candidates, config, coverBonus = CoverArtCompare.bonusFor(context, song.filePath))
}.getOrNull()?.takeIf { it.synced }
// The same impostor guard applies to rescues: a canonicalized identity whose artist agrees with none
// of the file's readings must be confirmed by the cover before batch saves it unattended.
if (rescued != null && !confirmedByCover(rescued.artist)) rescued = null
}
if (rescued != null) {
val songInfo = SongInfo(songName = rescued.title, artistName = rescued.artist)
Expand All @@ -534,7 +544,7 @@ suspend fun matchAndSaveSong(
// "N unsynced available" and let the user add them all with one tap later.
if (addUnsyncedFallback || onPlainAvailable != null) {
val plain = runCatching { matcher.fetchPlainLyrics(local, candidates, config) }.getOrNull()
val plainBody = plain?.plainLyrics
val plainBody = plain?.plainLyrics?.takeIf { confirmedByCover(plain.result.artist) }
if (plainBody != null) {
val pTitle = plain.result.title
val pArtist = plain.result.artist
Expand Down
21 changes: 21 additions & 0 deletions app/src/main/java/pl/lambada/songsync/util/matching/TextMatch.kt
Original file line number Diff line number Diff line change
Expand Up @@ -83,17 +83,38 @@ object TextMatch {
s = noiseBrackets.replace(s, " ")
s = leadingTrackNo.replace(s, "")
s = garbledChars.replace(s, " ")
// Double quotes around a title ('Summer Cem - "TMM TMM"') defeat provider search — drop them.
// Apostrophes are kept: they are part of words ("don't").
s = s.replace(Regex("""["“”„«»]"""), " ")
s = s.replace('_', ' ')
s = s.replace(Regex("""[\s]+"""), " ").trim()
// Drop dangling separators/brackets left behind by removals.
s = s.trim(' ', '-', '|', '/', '(', ')', '[', ']', '.', ',')
return s.trim()
}

/**
* Serbian/Russian Cyrillic -> Latin, aligned with what the accent-strip does to Latin diacritics
* (ж->z like ž->z, ш->s like š->s), so "Секси" compares equal to "Seksi" and a Cyrillic-indexed
* provider entry stops looking like a stranger to a Latin-tagged file.
*/
private val cyrillicMap = mapOf(
'а' to "a", 'б' to "b", 'в' to "v", 'г' to "g", 'д' to "d", 'ђ' to "dj", 'е' to "e", 'ё' to "e",
'ж' to "z", 'з' to "z", 'и' to "i", 'й' to "j", 'ј' to "j", 'к' to "k", 'л' to "l", 'љ' to "lj",
'м' to "m", 'н' to "n", 'њ' to "nj", 'о' to "o", 'п' to "p", 'р' to "r", 'с' to "s", 'т' to "t",
'ћ' to "c", 'у' to "u", 'ф' to "f", 'х' to "h", 'ц' to "c", 'ч' to "c", 'џ' to "dz", 'ш' to "s",
'щ' to "sh", 'ъ' to "", 'ы' to "y", 'ь' to "", 'э' to "e", 'ю' to "ju", 'я' to "ja",
)

private fun transliterateCyrillic(s: String): String =
if (s.none { it in 'Ѐ'..'ӿ' }) s
else buildString { for (c in s) append(cyrillicMap[c] ?: c.toString()) }

/** Aggressive normalization used only for *comparison* (not for display or queries). */
fun normalizeForCompare(raw: String?): String {
if (raw.isNullOrBlank()) return ""
var s = raw.lowercase(Locale.ROOT)
s = transliterateCyrillic(s)
s = noiseBrackets.replace(s, " ")
s = s.replace('_', ' ')
s = featRegex.replace(s, " ")
Expand Down
4 changes: 3 additions & 1 deletion app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,9 @@

<!-- Fast mode (Settings) -->
<string name="fast_mode">Fast matching</string>
<string name="fast_mode_desc">When searching a single song, race only the top two providers with a short timeout. Faster answers, but may find fewer songs than the full search.</string>
<string name="fast_mode_desc">Race all providers in parallel with tighter timeouts. Same providers and matching accuracy — just less waiting on slow servers.</string>
<string name="batch_fast_mode_desc">Same providers, same matching accuracy — tighter time budgets so slow servers cost seconds, not minutes.</string>
<string name="recommended">Recommended</string>

<!-- Lyrics preview -->
<string name="no_lyrics_in_file">No lyrics found in this file</string>
Expand Down
Loading
Loading