156 lines
3.5 KiB
Go
156 lines
3.5 KiB
Go
package autotag
|
|
|
|
// levenshtein returns the Levenshtein edit distance between a and
|
|
// b, operating on runes so multi-byte characters count as one edit.
|
|
// Allocates a single O(min(len)) scratch slice.
|
|
func levenshtein(a, b string) int {
|
|
ra := []rune(a)
|
|
rb := []rune(b)
|
|
|
|
if len(ra) == 0 {
|
|
return len(rb)
|
|
}
|
|
|
|
if len(rb) == 0 {
|
|
return len(ra)
|
|
}
|
|
|
|
if len(ra) > len(rb) {
|
|
ra, rb = rb, ra
|
|
}
|
|
|
|
prev := make([]int, len(ra)+1)
|
|
for i := range prev {
|
|
prev[i] = i
|
|
}
|
|
|
|
for j := 1; j <= len(rb); j++ {
|
|
curr0 := prev[0]
|
|
prev[0] = j
|
|
|
|
for i := 1; i <= len(ra); i++ {
|
|
cost := 1
|
|
if ra[i-1] == rb[j-1] {
|
|
cost = 0
|
|
}
|
|
|
|
newVal := min3(
|
|
prev[i]+1, // deletion
|
|
prev[i-1]+1, // insertion
|
|
curr0+cost, // substitution
|
|
)
|
|
curr0 = prev[i]
|
|
prev[i] = newVal
|
|
}
|
|
}
|
|
|
|
return prev[len(ra)]
|
|
}
|
|
|
|
func min3(a, b, c int) int {
|
|
if a < b {
|
|
if a < c {
|
|
return a
|
|
}
|
|
|
|
return c
|
|
}
|
|
|
|
if b < c {
|
|
return b
|
|
}
|
|
|
|
return c
|
|
}
|
|
|
|
// titleSimilarity returns a score in [0, 1] from normalized edit
|
|
// distance. 1.0 means identical after normalization, 0.0 means
|
|
// fully dissimilar. Both sides are normalized inside.
|
|
func titleSimilarity(a, b string) float64 {
|
|
na := Normalize(a)
|
|
nb := Normalize(b)
|
|
|
|
if na == "" && nb == "" {
|
|
return 1.0
|
|
}
|
|
|
|
longest := len(na)
|
|
if len(nb) > longest {
|
|
longest = len(nb)
|
|
}
|
|
|
|
if longest == 0 {
|
|
return 0.0
|
|
}
|
|
|
|
dist := levenshtein(na, nb)
|
|
|
|
return 1.0 - float64(dist)/float64(longest)
|
|
}
|
|
|
|
// Scoring weights for the per-track distance function. Local reads
|
|
// only — never written at runtime, so no mutex. Values stay small
|
|
// so a future tuning pass can nudge them without rescaling.
|
|
const (
|
|
weightTitle = 0.60
|
|
weightLength = 0.30
|
|
weightTrackNumber = 0.10
|
|
|
|
// Length deltas at or below lengthExactMs score 1.0 — matches
|
|
// the frontend's "subtle drift" threshold so anything the UI
|
|
// hides also doesn't count against the score. Past the
|
|
// threshold the penalty scales with delta / candidateMs (i.e.
|
|
// percentage of candidate-track length): a 5 s delta on a
|
|
// 4 min track is small, the same delta on a 30 s interlude is
|
|
// huge. At lengthFullyWrongPct of candidate length the score
|
|
// hits zero; beyond that it stays clamped to zero.
|
|
lengthExactMs int64 = 2000
|
|
lengthFullyWrongPct float64 = 0.20
|
|
|
|
// A title below titleReject has too little signal for this
|
|
// alignment to count as matched.
|
|
titleReject = 0.60
|
|
)
|
|
|
|
// lengthScore returns 1.0 for deltas <= lengthExactMs, 0.0 for
|
|
// deltas >= lengthFullyWrongPct of the candidate length, linear
|
|
// in delta-as-percentage-of-candidate-length between. When
|
|
// either side is zero (unknown), returns 0.5 so length is treated
|
|
// as neutral.
|
|
func lengthScore(localMs, candidateMs int64) float64 {
|
|
if localMs <= 0 || candidateMs <= 0 {
|
|
return 0.5
|
|
}
|
|
|
|
delta := localMs - candidateMs
|
|
if delta < 0 {
|
|
delta = -delta
|
|
}
|
|
|
|
if delta <= lengthExactMs {
|
|
return 1.0
|
|
}
|
|
|
|
pct := float64(delta) / float64(candidateMs)
|
|
if pct >= lengthFullyWrongPct {
|
|
return 0.0
|
|
}
|
|
|
|
return 1.0 - pct/lengthFullyWrongPct
|
|
}
|
|
|
|
// trackDistance scores how well one local track aligns with one
|
|
// candidate track. Higher is better. Caller decides what to do
|
|
// with the result — this function has no threshold.
|
|
func trackDistance(local LocalTrack, cand CandidateTrack) float64 {
|
|
title := titleSimilarity(local.Title, cand.Title)
|
|
length := lengthScore(local.LengthMillis, cand.LengthMillis)
|
|
|
|
var trackOK float64
|
|
if local.TrackNumber > 0 && local.TrackNumber == cand.Position {
|
|
trackOK = 1.0
|
|
}
|
|
|
|
return title*weightTitle + length*weightLength + trackOK*weightTrackNumber
|
|
}
|