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
18 changes: 10 additions & 8 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
## MusicResync v1.6.3 — hotfix
## MusicResync v1.6.4

### Fixed: wrong lyrics saved for songs with short, generic titles
v1.6.2's looser matching (which judges hits against the parsed filename, not just the raw tags) opened a precision hole: a song whose title matches some unrelated track exactly — "PETROV - RARI" getting the German "Rari" by lil doggo, "Numero - BMW" getting "BMW" by Cecilio G., "UKIC X PETROV - A TI?" getting "A Ti" by Dyango, "QuESt - Automatic" getting a Japanese "Automatic (Live)" — could ride in on the title alone, because the artist-less view of the track had nothing left to object to the wrong singer.
### 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".

New **wrong-singer veto**: whenever your file gives the matcher *any* artist guess (from the tags or the parsed filename) and a provider's result names an artist that agrees with **none** of those guesses, the result is rejected — across the normal search, the plain-lyrics fallback, and the last-resort rescue. An exact runtime match still overrides a disagreeing artist (that trust is what makes junk-tagged rips matchable at all), and a result with no artist at all is unaffected. A provider artist written in a different script (e.g. CJK vs. a Latin filename) now counts as disagreeing too.
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.

### Fixed: lyrics player not following the song / janky scrolling
The synced-lyrics player (the adjust-timing screen) only scrolled once the highlighted line left a visibility window, which looked like the list wasn't following the song at all — and then it lurched a whole screen at a time. It now glides continuously: each new line smoothly eases the list so the active line settles just above centre, Samsung-Music style. Seeks and taps on the progress bar still jump straight to the right spot.
### 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.

### Renamed leftovers
All user-facing "SongSync" texts now say **MusicResync** — including the `[by:]` tag written into generated .lrc files, the settings/about texts, and the welcome screen (all languages). The credit to the original SongSync project by Lambada10 remains, as it should.
Songs genuinely absent from every lyrics catalogue still fail honestly — no fluke lyrics.

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 = 163
versionName = "1.6.3"
versionCode = 164
versionName = "1.6.4"

vectorDrawables {
useSupportLibrary = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,15 @@ internal fun wrongSingerVetoed(
conf: ConfidenceBreakdown,
): Boolean {
if (conf.durationMatched) return false
return artistDisagreesWithAllGuesses(artistGuesses, resultArtist)
}

/**
* True when [resultArtist] is present and agrees with NONE of [artistGuesses]. This is the veto's core test,
* exposed separately so callers can also spot a hit that survived only on the exact-duration escape and ask
* for extra evidence (e.g. cover-art identification) before trusting it.
*/
internal fun artistDisagreesWithAllGuesses(artistGuesses: List<String>, resultArtist: String?): Boolean {
if (resultArtist.isNullOrBlank()) return false
val guesses = artistGuesses.filter { TextMatch.normalizeForCompare(it).isNotEmpty() }
if (guesses.isEmpty()) return false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class LastResortAPI {
val trackName: String? = null,
val artistName: String? = null,
val artworkUrl100: String? = null,
val trackTimeMillis: Double? = null,
)

@Serializable
Expand All @@ -45,6 +46,7 @@ class LastResortAPI {
val title: String? = null,
val artist: DeezerArtist? = null,
val album: DeezerAlbum? = null,
val duration: Double? = null,
)

@Serializable
Expand Down Expand Up @@ -104,6 +106,40 @@ class LastResortAPI {
return out.values.toList()
}

/** A possible identity for a bare-title query: canonical names plus cover art + runtime for verification. */
data class IdCandidate(val title: String, val artist: String, val coverUrl: String?, val durationSec: Double?)

/**
* Wide search used by cover-driven identification. Unlike [canonicalize] (top few rows only), this returns
* up to [limit] rows per service WITH duration and album art, so a niche track buried deep in the results
* ("Bounce" by Voyage sits at ~#18 for the bare query "bounce") can still be picked out by its cover.
*/
suspend fun identityCandidates(query: String, limit: Int = 25): List<IdCandidate> {
if (query.isBlank()) return emptyList()
val (itunesItems, deezerItems) = coroutineScope {
val i = async { itunes(query, limit) }
val d = async { deezer(query, limit) }
i.await() to d.await()
}
val out = LinkedHashMap<String, IdCandidate>()
for (item in deezerItems) {
val title = item.title?.takeIf { it.isNotBlank() } ?: continue
val artist = item.artist?.name?.takeIf { it.isNotBlank() } ?: continue
val cover = item.album?.cover_big?.takeIf { it.isNotBlank() } ?: item.album?.cover_xl
out.putIfAbsent("${artist.lowercase()}|${title.lowercase()}", IdCandidate(title, artist, cover, item.duration))
}
for (item in itunesItems) {
val title = item.trackName?.takeIf { it.isNotBlank() } ?: continue
val artist = item.artistName?.takeIf { it.isNotBlank() } ?: continue
val cover = item.artworkUrl100?.takeIf { it.isNotBlank() }?.let(::upscaleITunes)
out.putIfAbsent(
"${artist.lowercase()}|${title.lowercase()}",
IdCandidate(title, artist, cover, item.trackTimeMillis?.let { it / 1000.0 })
)
}
return out.values.toList()
}

/** Extra album-cover candidates for the thumbnail picker, beyond what Apple/Spotify already provided. */
suspend fun covers(title: String, artist: String, max: Int = 4): List<String> {
val query = "$artist $title"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import pl.lambada.songsync.data.remote.lyrics_providers.LyricsProviderService
import pl.lambada.songsync.data.remote.lyrics_providers.MatchConfig
import pl.lambada.songsync.data.remote.lyrics_providers.ScoredHit
import pl.lambada.songsync.data.remote.lyrics_providers.SmartLyricsMatcher
import pl.lambada.songsync.data.remote.lyrics_providers.artistGuessesFor
import pl.lambada.songsync.domain.model.SongInfo
import pl.lambada.songsync.ui.LocalSong
import pl.lambada.songsync.util.Providers
Expand All @@ -31,6 +32,7 @@ import pl.lambada.songsync.util.matching.QueryCandidate
import pl.lambada.songsync.util.matching.TextMatch
import com.kyant.taglib.TagLib
import pl.lambada.songsync.util.CoverArtCompare
import pl.lambada.songsync.util.CoverIdentity
import pl.lambada.songsync.util.embedCoverInFile
import pl.lambada.songsync.util.embedLyricsInFile
import pl.lambada.songsync.util.ext.getVersion
Expand Down Expand Up @@ -124,9 +126,19 @@ class LyricsFetchViewModel(
queryArtistName.takeIf { !TextMatch.isJunkArtist(it) },
durationSec,
)
val candidates = FilenameParser.candidates(querySongName, queryArtistName, source?.filePath)
val parsed = FilenameParser.candidates(querySongName, queryArtistName, source?.filePath)
.ifEmpty { listOf(QueryCandidate(querySongName, queryArtistName ?: "", MatchStrategy.TAGS)) }

// Cover-driven identification (same as batch): a file with no usable artist anywhere is only
// identifiable by its embedded album art — discover the identity and lead the search with it.
val candidates = if (artistGuessesFor(local, parsed).isEmpty()) {
val id = runCatching {
CoverIdentity.discover(context, source?.filePath, parsed.map { it.asSearchString() }, durationSec)
}.getOrNull()
if (id != null) listOf(QueryCandidate(id.title, id.artist, MatchStrategy.COVER_IDENTITY)) + parsed
else parsed
} else parsed

// Selected provider first, then the user's configured fallback chain (Settings > Provider order).
// Providers the user disabled are not queried automatically (they stay available via the
// per-provider dropdown/retry).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import java.net.URL
object CoverArtCompare {

/** Hamming distance (of 64 bits) at or below which two covers count as "the same picture". */
private const val MATCH_MAX_DISTANCE = 10
const val MATCH_MAX_DISTANCE = 10

/** 64-bit average hash of a bitmap: downscale to 8x8, gray, threshold on the mean. */
fun aHash64(bitmap: Bitmap): Long {
Expand Down
61 changes: 61 additions & 0 deletions app/src/main/java/pl/lambada/songsync/util/CoverIdentity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package pl.lambada.songsync.util

import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import pl.lambada.songsync.data.remote.lyrics_providers.others.LastResortAPI
import kotlin.math.abs

/**
* Cover-driven identification for files whose metadata carries no usable artist at all (title tag "Bounce",
* artist "Unknown", filename "Bounce.mp3"). Such a file is indistinguishable from every other same-titled
* song by text alone — but its embedded album art isn't. This searches iTunes/Deezer widely with the bare
* title, hashes each result's cover, and treats a perceptual match against the file's own art (plus a
* compatible runtime) as the file's real identity — recovering the artist the tags never had.
*
* The discovered identity is used in two ways by the matcher:
* - as a leading query candidate ("Voyage Bounce" instead of just "Bounce"), and
* - as a trusted artist guess, so a same-titled song by someone else can finally be vetoed.
*/
object CoverIdentity {

data class Discovered(val title: String, val artist: String, val distance: Int)

/** Cap on cover-image downloads per song — identification must stay cheap even for common titles. */
private const val MAX_COVER_FETCHES = 12

/** Runtime window for identity candidates. Lenient on purpose: the cover is the primary evidence. */
private const val DURATION_WINDOW_SEC = 15.0

suspend fun discover(
context: Context,
filePath: String?,
titleQueries: List<String>,
durationSec: Double?,
api: LastResortAPI = LastResortAPI(),
): Discovered? = withContext(Dispatchers.IO) {
if (filePath == null) return@withContext null
val localHash = CoverArtCompare.localCoverHash(context, filePath) ?: return@withContext null
val seenUrls = HashSet<String>()
var fetches = 0
var best: Discovered? = null
for (query in titleQueries.filter { it.isNotBlank() }.distinct().take(2)) {
val rows = runCatching { api.identityCandidates(query) }.getOrDefault(emptyList())
val plausible = rows.filter { r ->
durationSec == null || r.durationSec == null || abs(r.durationSec - durationSec) <= DURATION_WINDOW_SEC
}
for (row in plausible) {
if (fetches >= MAX_COVER_FETCHES) break
val url = row.coverUrl ?: continue
if (!seenUrls.add(url)) continue
fetches++
val remote = CoverArtCompare.remoteCoverHash(url) ?: continue
val dist = CoverArtCompare.hammingDistance(localHash, remote)
if (dist <= CoverArtCompare.MATCH_MAX_DISTANCE && dist < (best?.distance ?: Int.MAX_VALUE))
best = Discovered(row.title, row.artist, dist)
}
if (best != null) break
}
best
}
}
38 changes: 37 additions & 1 deletion app/src/main/java/pl/lambada/songsync/util/LyricsUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import pl.lambada.songsync.data.remote.lyrics_providers.LyricsProviderService
import pl.lambada.songsync.data.remote.lyrics_providers.MatchConfig
import pl.lambada.songsync.data.remote.lyrics_providers.ScoredHit
import pl.lambada.songsync.data.remote.lyrics_providers.SmartLyricsMatcher
import pl.lambada.songsync.data.remote.lyrics_providers.artistDisagreesWithAllGuesses
import pl.lambada.songsync.data.remote.lyrics_providers.artistGuessesFor
import pl.lambada.songsync.data.UserSettingsController
import pl.lambada.songsync.domain.model.Song
import pl.lambada.songsync.domain.model.SongInfo
Expand All @@ -27,7 +29,9 @@ import pl.lambada.songsync.util.matching.FilenameParser
import pl.lambada.songsync.util.matching.LocalTrack
import pl.lambada.songsync.util.matching.LrcPrescan
import pl.lambada.songsync.util.matching.LyricState
import pl.lambada.songsync.util.matching.MatchStrategy
import pl.lambada.songsync.util.matching.MatchTier
import pl.lambada.songsync.util.matching.QueryCandidate
import pl.lambada.songsync.util.matching.SongMatchInfo
import pl.lambada.songsync.util.matching.TextMatch
import kotlinx.coroutines.ensureActive
Expand Down Expand Up @@ -432,7 +436,25 @@ suspend fun matchAndSaveSong(
durationSec = song.durationMs?.let { it / 1000.0 },
album = song.album,
)
val candidates = FilenameParser.candidates(song.title, song.artist, song.filePath)
val parsed = FilenameParser.candidates(song.title, song.artist, song.filePath)

// Cover-driven identification: a file with NO usable artist anywhere ("Bounce", artist "Unknown") cannot
// be told apart from every other same-titled song by text — but its embedded cover can. Discover the real
// identity up front and lead with it, both as a query ("Voyage Bounce") and as an artist guess the
// wrong-singer veto can finally use.
var discovered: CoverIdentity.Discovered? = null
var discoveryRan = false
var candidates = parsed
if (artistGuessesFor(local, parsed).isEmpty()) {
discoveryRan = true
discovered = runCatching {
CoverIdentity.discover(context, song.filePath, parsed.map { it.asSearchString() }, local.durationSec)
}.getOrNull()
discovered?.let {
candidates = listOf(QueryCandidate(it.title, it.artist, MatchStrategy.COVER_IDENTITY)) + parsed
}
}
val artistGuesses = artistGuessesFor(local, candidates)

// Which providers were actually queried for this song, in order. Persisted on the outcome so the
// song's provider list can later show "found / no match / not tried" per provider.
Expand All @@ -459,6 +481,20 @@ suspend fun matchAndSaveSong(
var lyrics: String? = null
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
}
val l = runCatching { matcher.fetchLyrics(hit, config) }.getOrNull()
if (!l.isNullOrBlank()) { chosen = hit; lyrics = l; break }
}
Expand Down
Loading
Loading