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

### Batch matching now finds what single-song search finds (fixes reports of "it works when I open the song, but not in batch")
The batch downloader was missing two things the single-song screen already had, which made it fail on songs like **Ariana Grande - Focus** (artist tag `Unknown`), **D-Devils - 6th Gate**, and **Tyler, The Creator - Tamale**:
### 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.

- **Every provider hit is now also scored against the parsed filename/title view of the track, not just the raw tags.** A junk tag like title `"Ariana Grande - Focus"` / artist `"Unknown"` used to make the scorer reject the correct hit outright (it saw a weak title match and a "disagreeing" artist); it's now also compared against the cleaned `Focus` / `Ariana Grande` reading, which matches. A safety guard still requires an exact runtime match before an artist-less reading can carry a result to auto-accept, so a same-titled song by the wrong artist still can't sneak through.
- **Batch now runs the same last-resort rescue** (canonicalizing a messy name via iTunes/Deezer, then re-querying) that only the single-song screen had before — this was the actual reason a song could be found by opening it manually but not during a batch run. Rescues are still validated against duration/title evidence and are always saved as "review", never silently auto-accepted.
- Placeholder artist tags (`Unknown`, `Various Artists`, `<unknown>`, …) are now treated as missing information instead of as evidence that contradicts a correct match.
- Filenames with a `(feat. ...)` clause followed by junk (e.g. `Biba - Harli Kvin (feat. AV47) [Official Audio] _420(MP3_320K).mp3`) are now parsed correctly, and trailing junk numbers left over from ripper suffixes are stripped for an extra fallback query.
- Rescued matches whose album art visually matches your file's embedded cover get a small confidence boost — a *different* cover is never held against a match, since re-releases and singles legitimately use different art.
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.

### Fast mode (new, opt-in)
A new toggle in Settings races only the two fastest providers with a short timeout for single-song search — quicker answers at a small cost to thoroughness. Off by default; the full search remains the default everywhere.
### 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.

### Fixed: lyrics preview showing wrong state / stuck loading (#11)
The synced-lyrics player and the batch "tap to view lyrics" preview only ever checked for a sidecar `.lrc` file, so a song with lyrics *embedded in the audio tags* looked like it had none — the player said "no lyrics found" and the batch preview spun forever. Both now also check embedded lyrics and always resolve to a real result.

### Removed redundant menu options (#12)
The home screen's three-dot menu now only has Settings — "batch download" already has its own button, and the lyrics-provider order lives in Settings, so the duplicate entries are gone.

### More honest provider status
In the single-song screen's provider dropdown, a provider that found usable lyrics but simply wasn't the best match is now shown as "found lyrics" instead of a red X. Only providers that were actually queried can now be marked as having failed — ones skipped by an early match stay "not tried yet".

### Polish
Saving lyrics (.lrc or embedded) now has a proper success animation instead of an instant text swap.
### 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.

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 = 162
versionName = "1.6.2"
versionCode = 163
versionName = "1.6.3"

vectorDrawables {
useSupportLibrary = true
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/pl/lambada/songsync/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import pl.lambada.songsync.util.dataStore
import java.io.File

/**
* The main activity of the SongSync app.
* The main activity of the MusicResync app.
*/
class MainActivity : ComponentActivity() {
private val lyricsProviderService = LyricsProviderService()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import pl.lambada.songsync.util.matching.MatchStrategy
import pl.lambada.songsync.util.matching.MatchTier
import pl.lambada.songsync.util.matching.ProviderResult
import pl.lambada.songsync.util.matching.QueryCandidate
import pl.lambada.songsync.util.matching.TextMatch
import pl.lambada.songsync.util.networking.withRetry

private const val TAG = "MusicResync"
Expand Down Expand Up @@ -140,7 +141,9 @@ class SmartLyricsMatcher(
val key = "${hit.provider}|${hit.result.title}|${hit.result.artist}|${hit.result.durationSec}"
val existing = hits[key]
if (existing == null || hit.confidence.score > existing.confidence.score) hits[key] = hit
if (hit.confidence.score > best) {
// REJECT-tier hits (e.g. wrong-singer vetoes) keep their raw score for the manual UI but must
// never drive the early stop — a vetoed 90% "match" isn't a reason to cancel other providers.
if (hit.tier != MatchTier.REJECT && hit.confidence.score > best) {
best = hit.confidence.score
log(" [${provider.displayName}] ${hit.strategy.label} -> ${hit.result.title} - ${hit.result.artist} (${hit.confidence.percent()}%${if (hit.confidence.durationMatched) ", duration✓" else ""})")
}
Expand Down Expand Up @@ -170,6 +173,7 @@ class SmartLyricsMatcher(
): List<ScoredHit> {
val providerHits = LinkedHashMap<String, ScoredHit>()
var providerBest = 0.0
val artistGuesses = artistGuessesFor(local, candidates)
for (cand in candidates) {
val query = cand.asSearchString()
if (query.isBlank()) continue
Expand All @@ -180,11 +184,14 @@ class SmartLyricsMatcher(
Providers.LYRICSPLUS, Providers.BETTERLYRICS -> searchDirect(provider, local, cand, config, log)
else -> searchGeneric(provider, local, cand, config, log)
}
for (hit in found) {
for (raw in found) {
val hit = if (wrongSingerVetoed(artistGuesses, raw.result.artist, raw.confidence))
raw.copy(confidence = raw.confidence.copy(tier = MatchTier.REJECT))
else raw
val key = "${hit.provider}|${hit.result.title}|${hit.result.artist}|${hit.result.durationSec}"
val existing = providerHits[key]
if (existing == null || hit.confidence.score > existing.confidence.score) providerHits[key] = hit
if (hit.confidence.score > providerBest) providerBest = hit.confidence.score
if (hit.tier != MatchTier.REJECT && hit.confidence.score > providerBest) providerBest = hit.confidence.score
}
if (providerBest >= ConfidenceScorer.AUTO_ACCEPT_THRESHOLD) break
delay(config.requestDelayMs)
Expand All @@ -208,6 +215,7 @@ class SmartLyricsMatcher(
): PlainHit? = withTimeoutOrNull(config.providerTimeoutMs) {
// Same hard time budget as a regular provider (issue #9): this fallback runs after a failed search, so
// letting its retries stack would just re-create the long wait we removed.
val artistGuesses = artistGuessesFor(local, candidates)
for (cand in candidates) {
val query = cand.asSearchString()
if (query.isBlank()) continue
Expand All @@ -222,7 +230,7 @@ class SmartLyricsMatcher(
Triple(pr, scoreFor(local, pr, cand), r.plainLyrics!!)
}
.sortedByDescending { it.second.score }
.firstOrNull { it.second.tier != MatchTier.REJECT }
.firstOrNull { it.second.tier != MatchTier.REJECT && !wrongSingerVetoed(artistGuesses, it.first.artist, it.second) }

if (best != null) {
log(" [plain fallback] ${best.first.title} - ${best.first.artist} (${best.second.percent()}%)")
Expand Down Expand Up @@ -416,7 +424,7 @@ class SmartLyricsMatcher(
val key = "${hit.provider}|${hit.result.title}|${hit.result.artist}|${hit.result.durationSec}"
val existing = hits[key]
if (existing == null || hit.confidence.score > existing.confidence.score) hits[key] = hit
if (hit.confidence.score >= ConfidenceScorer.AUTO_ACCEPT_THRESHOLD) break
if (hit.tier != MatchTier.REJECT && hit.confidence.score >= ConfidenceScorer.AUTO_ACCEPT_THRESHOLD) break
}
return hits.values.sortedByDescending { it.confidence.score }
}
Expand Down Expand Up @@ -502,6 +510,40 @@ internal fun scoreHitAgainstViews(local: LocalTrack, pr: ProviderResult, cand: Q
else best
}

/**
* Every usable artist guess for the local file: the raw tag (when not junk-filtered away) plus each parsed
* candidate's artist. This is what the wrong-singer veto compares a provider's artist against.
*/
internal fun artistGuessesFor(local: LocalTrack, candidates: List<QueryCandidate>): List<String> =
(listOfNotNull(local.artist) + candidates.mapNotNull { it.artist })
.filter { it.isNotBlank() }
.distinct()

/**
* The wrong-singer veto (v1.6.3 hotfix). Scoring hits against artist-less views of the local track (added in
* v1.6.2 for junk-tag files) opened a hole: a short generic title ("Rari", "BMW", "A Ti", "Automatic") matches
* some unrelated song exactly, and with no artist on the winning view nothing was left to veto the wrong singer
* — so batch runs saved lyrics of a completely different track. This closes the hole from the outside: when the
* file gives us artist guesses ([artistGuessesFor]) and the provider names an artist that agrees with NONE of
* them, the hit is vetoed. Two escapes keep the v1.6.2 recall wins intact:
* - an exact runtime match still overrides a disagreeing artist (the same trust the scorer itself applies);
* - a provider with no artist at all is not vetoed (nothing to disagree with).
* A provider artist in a different script (normalizes to nothing comparable) counts as disagreeing — it cannot
* be the same name our Latin guess spells, and the duration escape still saves genuine cross-script matches.
*/
internal fun wrongSingerVetoed(
artistGuesses: List<String>,
resultArtist: String?,
conf: ConfidenceBreakdown,
): Boolean {
if (conf.durationMatched) return false
if (resultArtist.isNullOrBlank()) return false
val guesses = artistGuesses.filter { TextMatch.normalizeForCompare(it).isNotEmpty() }
if (guesses.isEmpty()) return false
if (TextMatch.normalizeForCompare(resultArtist).isEmpty()) return true
return guesses.maxOf { TextMatch.similarity(it, resultArtist) } < 0.50
}

/** A raw rescued lyrics candidate (a canonicalized provider result + its lyrics) awaiting confidence validation. */
data class RescueCandidate(
val result: ProviderResult,
Expand Down Expand Up @@ -539,6 +581,7 @@ data class RescueCandidate(
fun selectBestRescue(localViews: List<LocalTrack>, candidates: List<RescueCandidate>): SmartLyricsMatcher.LastResortHit? {
val durationView = localViews.firstOrNull { it.durationSec != null && it.durationSec > 0 }
val localHasDuration = durationView?.durationSec != null
val artistGuesses = localViews.mapNotNull { it.artist }.filter { it.isNotBlank() }.distinct()

data class Scored(val c: RescueCandidate, val synced: Boolean, val conf: ConfidenceBreakdown, val ranking: Double)

Expand All @@ -559,6 +602,10 @@ fun selectBestRescue(localViews: List<LocalTrack>, candidates: List<RescueCandid
// "$uicideboy$ - Every Day I Pimp" -> "All of My Problems Always Involve Me" while still allowing a
// rescue through when the best cleaned candidate title really matches or the duration is exact.
if (!conf.durationMatched && conf.title < 0.55) return@mapNotNull null
// Wrong-singer veto: the max-over-views confidence above may have come from an artist-less view, which
// cannot object to a different singer with the same generic title ("Rari" by someone else entirely).
// When any local view supplies an artist and the rescue's artist agrees with none of them, drop it.
if (wrongSingerVetoed(artistGuesses, c.result.artist, conf)) return@mapNotNull null

// Album-art agreement is additive-only: it lifts the ranking of a candidate whose cover matches the
// local file's embedded art, but a mismatch never pushes a candidate below the acceptance gates above.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import java.nio.charset.StandardCharsets

class LRCLibAPI {
private val baseURL = "https://lrclib.net/api/"
private val userAgent = "MusicResync v1.0 (https://github.com/Lambada10/SongSync)"
private val userAgent = "MusicResync (https://github.com/DynamycSound/MusicResync)"

/**
* MusicResync: returns the *full* list of search hits (each already carries duration, album and synced
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package pl.lambada.songsync.ui.screens.player

import android.media.MediaPlayer
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
Expand Down Expand Up @@ -88,7 +91,6 @@ import pl.lambada.songsync.util.matching.LyricState
import pl.lambada.songsync.util.showToast
import pl.lambada.songsync.util.toCrlf
import java.io.File
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt

Expand Down Expand Up @@ -312,20 +314,27 @@ fun SyncedLyricsPlayerScreen(
else lines.indexOfLast { it.timeMs <= positionMs + offsetMs.roundToInt() }.coerceAtLeast(0)
}
}
// Word/line-dense lyrics were stuttering because we launched a new animateScrollToItem on *every* highlight
// change. Keep the current line within a comfortable window and only scroll when it leaves that window;
// small shifts jump immediately (no competing animations), while larger jumps still animate.
LaunchedEffect(currentIndex) {
// Karaoke-style follow: every highlight change glides the list so the active line settles just above
// centre. animateScrollBy restarts smoothly from wherever the list currently is (a new line simply
// retargets the glide), giving one continuous crawl that visibly tracks the song. The old logic only
// scrolled once the line left a visibility window, which read as "the player isn't following the song"
// and then lurched a whole window at a time.
LaunchedEffect(currentIndex, lyricsLoaded) {
if (lines.isEmpty()) return@LaunchedEffect
val visible = listState.layoutInfo.visibleItemsInfo.size.coerceAtLeast(1)
val first = listState.firstVisibleItemIndex
val lowerBound = first + 1
val upperBound = first + visible - 3
if (currentIndex in lowerBound..upperBound) return@LaunchedEffect
val target = (currentIndex - 3).coerceAtLeast(0)
runCatching {
if (abs(target - first) <= 2) listState.scrollToItem(target)
else listState.animateScrollToItem(target)
val info = listState.layoutInfo
val item = info.visibleItemsInfo.firstOrNull { it.index == currentIndex }
if (item != null) {
// Glide the line's centre to an anchor ~38% down the viewport (slightly above centre).
val viewport = info.viewportEndOffset - info.viewportStartOffset
val anchor = info.viewportStartOffset + viewport * 0.38f
val delta = item.offset + item.size / 2f - anchor
listState.animateScrollBy(delta, tween(durationMillis = 600, easing = FastOutSlowInEasing))
} else {
// Line is far off-screen (seek, tapped the bar, opened mid-song): land near the anchor
// directly; subsequent lines glide from there.
listState.animateScrollToItem((currentIndex - 2).coerceAtLeast(0))
}
}
}

Expand Down
Loading
Loading