wip on autotagging
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user