mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-06-03 20:13:03 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,732 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SnapshotProvider interface {
|
||||
Snapshot(ctx context.Context, userID string) (CatalogSnapshot, error)
|
||||
}
|
||||
|
||||
type Engine struct {
|
||||
now func() time.Time
|
||||
contentWeight float64
|
||||
collabWeight float64
|
||||
popularityWeight float64
|
||||
explorationWeight float64
|
||||
diversityLambda float64
|
||||
}
|
||||
|
||||
type EngineConfig struct {
|
||||
Now func() time.Time
|
||||
ContentWeight float64
|
||||
CollabWeight float64
|
||||
PopularityWeight float64
|
||||
ExplorationWeight float64
|
||||
DiversityLambda float64
|
||||
}
|
||||
|
||||
func NewEngine(cfg EngineConfig) *Engine {
|
||||
if cfg.Now == nil {
|
||||
cfg.Now = time.Now
|
||||
}
|
||||
return &Engine{
|
||||
now: cfg.Now,
|
||||
contentWeight: cfg.ContentWeight,
|
||||
collabWeight: cfg.CollabWeight,
|
||||
popularityWeight: cfg.PopularityWeight,
|
||||
explorationWeight: cfg.ExplorationWeight,
|
||||
diversityLambda: cfg.DiversityLambda,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) Recommend(ctx context.Context, provider SnapshotProvider, req RecommendRequest) ([]Recommendation, TasteProfile, error) {
|
||||
if strings.TrimSpace(req.UserID) == "" {
|
||||
return nil, TasteProfile{}, errors.New("user_id is required")
|
||||
}
|
||||
if req.Limit <= 0 {
|
||||
req.Limit = 20
|
||||
}
|
||||
if req.Limit > 100 {
|
||||
req.Limit = 100
|
||||
}
|
||||
|
||||
snapshot, err := provider.Snapshot(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return nil, TasteProfile{}, err
|
||||
}
|
||||
if len(snapshot.Tracks) == 0 {
|
||||
return nil, TasteProfile{}, errors.New("catalog is empty")
|
||||
}
|
||||
|
||||
b := bounds(snapshot.Tracks)
|
||||
byTrackID := indexTracks(snapshot.Tracks)
|
||||
userInteractions := interactionsForUser(snapshot.Interactions, req.UserID)
|
||||
tasteVector, positiveIDs, confidence, hasTasteAudio := e.tasteVector(snapshot.Tracks, byTrackID, userInteractions, req, b)
|
||||
preferences := e.preferenceProfile(byTrackID, userInteractions, req)
|
||||
neighborScores := e.collaborativeScores(snapshot.Interactions, req.UserID)
|
||||
controls := mergeControls(snapshot.Controls, req)
|
||||
candidates := make([]Recommendation, 0, len(snapshot.Tracks))
|
||||
|
||||
for _, track := range snapshot.Tracks {
|
||||
if shouldFilter(track, positiveIDs, controls, req) {
|
||||
continue
|
||||
}
|
||||
|
||||
trackVector := normalize(track.Features, b)
|
||||
trackHasAudio := hasAudioFeatures(track.Features)
|
||||
metadataScore := metadataAffinity(track, preferences)
|
||||
contentScore := metadataScore
|
||||
if hasTasteAudio && trackHasAudio {
|
||||
contentScore = clamp01(cosine(tasteVector, trackVector)*0.78 + metadataScore*0.22)
|
||||
}
|
||||
|
||||
collabScore := clamp01(neighborScores[track.ID])
|
||||
popularityScore := popularityFit(track.Popularity, req.Mode)
|
||||
explorationScore := 0.5
|
||||
if hasTasteAudio && trackHasAudio {
|
||||
explorationScore = e.explorationScore(track, trackVector, tasteVector, req)
|
||||
}
|
||||
safetyScore := safetyScore(track, controls) * (1 - 0.52*negativeAffinity(track, preferences))
|
||||
commercialScore := commercialScore(track, contentScore)
|
||||
|
||||
final := 0.0
|
||||
final += e.contentWeight * contentScore
|
||||
final += e.collabWeight * collabScore
|
||||
final += e.popularityWeight * popularityScore
|
||||
final += e.explorationWeight * explorationScore
|
||||
final *= safetyScore
|
||||
final += commercialScore
|
||||
|
||||
candidates = append(candidates, Recommendation{
|
||||
Track: track,
|
||||
Score: final,
|
||||
Reason: reason(contentScore, collabScore, explorationScore, metadataScore, hasTasteAudio && trackHasAudio),
|
||||
ScoreBreakdown: ScoreBreakdown{
|
||||
Content: round(contentScore),
|
||||
Collaborative: round(collabScore),
|
||||
Popularity: round(popularityScore),
|
||||
Exploration: round(explorationScore),
|
||||
Safety: round(safetyScore),
|
||||
Commercial: round(commercialScore),
|
||||
Final: round(final),
|
||||
},
|
||||
Explanation: featureExplanation(tasteVector, trackVector),
|
||||
})
|
||||
}
|
||||
|
||||
slices.SortFunc(candidates, func(a, b Recommendation) int {
|
||||
return cmp.Compare(b.Score, a.Score)
|
||||
})
|
||||
|
||||
selected := e.diversify(candidates, req.Limit, b)
|
||||
for i := range selected {
|
||||
selected[i].Rank = i + 1
|
||||
selected[i].Score = round(selected[i].Score)
|
||||
selected[i].ScoreBreakdown.Final = selected[i].Score
|
||||
}
|
||||
|
||||
profile := TasteProfile{
|
||||
UserID: req.UserID,
|
||||
Vector: arrayToSlice(tasteVector),
|
||||
TopGenres: topGenres(snapshot.Tracks, byTrackID, userInteractions),
|
||||
InteractionCount: len(userInteractions),
|
||||
Confidence: round(confidence),
|
||||
ExplorationReadiness: round(explorationReadiness(confidence, userInteractions)),
|
||||
UpdatedAt: e.now().UTC(),
|
||||
}
|
||||
return selected, profile, nil
|
||||
}
|
||||
|
||||
func (e *Engine) TasteProfile(ctx context.Context, provider SnapshotProvider, userID string) (TasteProfile, error) {
|
||||
recs, profile, err := e.Recommend(ctx, provider, RecommendRequest{UserID: userID, Limit: 1})
|
||||
if err != nil {
|
||||
return TasteProfile{}, err
|
||||
}
|
||||
_ = recs
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (e *Engine) tasteVector(tracks []Track, byTrackID map[string]Track, interactions []Interaction, req RecommendRequest, b featureBounds) ([featureCount]float64, map[string]struct{}, float64, bool) {
|
||||
var sum [featureCount]float64
|
||||
var total float64
|
||||
var audioTotal float64
|
||||
positive := make(map[string]struct{})
|
||||
|
||||
for _, seedID := range req.SeedTrackIDs {
|
||||
track, ok := byTrackID[seedID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
positive[seedID] = struct{}{}
|
||||
if !hasAudioFeatures(track.Features) {
|
||||
continue
|
||||
}
|
||||
addWeighted(&sum, normalize(track.Features, b), 1.25)
|
||||
total += 1.25
|
||||
audioTotal += 1.25
|
||||
}
|
||||
|
||||
for _, interaction := range interactions {
|
||||
track, ok := byTrackID[interaction.TrackID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
weight := interactionWeight(interaction)
|
||||
if weight > 0 {
|
||||
positive[interaction.TrackID] = struct{}{}
|
||||
}
|
||||
if !hasAudioFeatures(track.Features) {
|
||||
continue
|
||||
}
|
||||
decay := timeDecay(e.now(), interaction.OccurredAt)
|
||||
addWeighted(&sum, normalize(track.Features, b), weight*decay)
|
||||
total += math.Abs(weight * decay)
|
||||
audioTotal += math.Abs(weight * decay)
|
||||
}
|
||||
|
||||
if req.FeatureTargets != nil {
|
||||
addWeighted(&sum, normalize(*req.FeatureTargets, b), 1.15)
|
||||
total += 1.15
|
||||
audioTotal += 1.15
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
return catalogCentroid(tracks, b), positive, 0, false
|
||||
}
|
||||
for i := range featureCount {
|
||||
sum[i] = clamp01(sum[i] / total)
|
||||
}
|
||||
confidence := clamp01(math.Log1p(total) / math.Log(32))
|
||||
return sum, positive, confidence, audioTotal > 0
|
||||
}
|
||||
|
||||
func (e *Engine) collaborativeScores(interactions []Interaction, activeUserID string) map[string]float64 {
|
||||
userRatings := make(map[string]map[string]float64)
|
||||
for _, interaction := range interactions {
|
||||
if userRatings[interaction.UserID] == nil {
|
||||
userRatings[interaction.UserID] = make(map[string]float64)
|
||||
}
|
||||
userRatings[interaction.UserID][interaction.TrackID] += interactionWeight(interaction)
|
||||
}
|
||||
|
||||
active := userRatings[activeUserID]
|
||||
scores := make(map[string]float64)
|
||||
if len(active) == 0 {
|
||||
return scores
|
||||
}
|
||||
|
||||
for userID, ratings := range userRatings {
|
||||
if userID == activeUserID {
|
||||
continue
|
||||
}
|
||||
similarity, overlap := pearson(active, ratings)
|
||||
if similarity <= 0 {
|
||||
continue
|
||||
}
|
||||
similarity *= float64(overlap) / float64(overlap+3)
|
||||
for trackID, rating := range ratings {
|
||||
if _, alreadyKnown := active[trackID]; alreadyKnown || rating <= 0 {
|
||||
continue
|
||||
}
|
||||
scores[trackID] += similarity * rating
|
||||
}
|
||||
}
|
||||
|
||||
maxScore := 0.0
|
||||
for _, score := range scores {
|
||||
if score > maxScore {
|
||||
maxScore = score
|
||||
}
|
||||
}
|
||||
if maxScore == 0 {
|
||||
return scores
|
||||
}
|
||||
for trackID, score := range scores {
|
||||
scores[trackID] = clamp01(score / maxScore)
|
||||
}
|
||||
return scores
|
||||
}
|
||||
|
||||
type preferenceProfile struct {
|
||||
artists map[string]float64
|
||||
genres map[string]float64
|
||||
negativeArtists map[string]float64
|
||||
negativeGenres map[string]float64
|
||||
}
|
||||
|
||||
func (e *Engine) preferenceProfile(byTrackID map[string]Track, interactions []Interaction, req RecommendRequest) preferenceProfile {
|
||||
profile := preferenceProfile{
|
||||
artists: make(map[string]float64),
|
||||
genres: make(map[string]float64),
|
||||
negativeArtists: make(map[string]float64),
|
||||
negativeGenres: make(map[string]float64),
|
||||
}
|
||||
|
||||
for _, seedID := range req.SeedTrackIDs {
|
||||
if track, ok := byTrackID[seedID]; ok {
|
||||
addTrackPreference(profile.artists, profile.genres, track, 1.25)
|
||||
}
|
||||
}
|
||||
|
||||
for _, interaction := range interactions {
|
||||
track, ok := byTrackID[interaction.TrackID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
weight := interactionWeight(interaction) * timeDecay(e.now(), interaction.OccurredAt)
|
||||
switch {
|
||||
case weight > 0:
|
||||
addTrackPreference(profile.artists, profile.genres, track, weight)
|
||||
case weight < 0:
|
||||
addTrackPreference(profile.negativeArtists, profile.negativeGenres, track, math.Abs(weight))
|
||||
}
|
||||
}
|
||||
|
||||
normalizeMap(profile.artists)
|
||||
normalizeMap(profile.genres)
|
||||
normalizeMap(profile.negativeArtists)
|
||||
normalizeMap(profile.negativeGenres)
|
||||
return profile
|
||||
}
|
||||
|
||||
func addTrackPreference(artists, genres map[string]float64, track Track, weight float64) {
|
||||
if artist := normalizedToken(track.Artist); artist != "" {
|
||||
artists[artist] += weight
|
||||
}
|
||||
for _, genre := range track.Genres {
|
||||
if genre = normalizedToken(genre); genre != "" {
|
||||
genres[genre] += weight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeMap(values map[string]float64) {
|
||||
maxValue := 0.0
|
||||
for _, value := range values {
|
||||
maxValue = math.Max(maxValue, value)
|
||||
}
|
||||
if maxValue == 0 {
|
||||
return
|
||||
}
|
||||
for key, value := range values {
|
||||
values[key] = clamp01(value / maxValue)
|
||||
}
|
||||
}
|
||||
|
||||
func metadataAffinity(track Track, profile preferenceProfile) float64 {
|
||||
artistScore := profile.artists[normalizedToken(track.Artist)]
|
||||
genreScore := genreAffinity(track.Genres, profile.genres)
|
||||
|
||||
switch {
|
||||
case artistScore == 0 && genreScore == 0:
|
||||
return 0.42
|
||||
case artistScore == 0:
|
||||
return clamp01(0.32 + 0.68*genreScore)
|
||||
case genreScore == 0:
|
||||
return clamp01(0.38 + 0.62*artistScore)
|
||||
default:
|
||||
return clamp01(0.48*artistScore + 0.52*genreScore)
|
||||
}
|
||||
}
|
||||
|
||||
func negativeAffinity(track Track, profile preferenceProfile) float64 {
|
||||
artistScore := profile.negativeArtists[normalizedToken(track.Artist)]
|
||||
genreScore := genreAffinity(track.Genres, profile.negativeGenres)
|
||||
return clamp01(math.Max(artistScore*0.9, genreScore*0.7))
|
||||
}
|
||||
|
||||
func genreAffinity(genres []string, profile map[string]float64) float64 {
|
||||
best := 0.0
|
||||
for _, genre := range genres {
|
||||
best = math.Max(best, profile[normalizedToken(genre)])
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func normalizedToken(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func popularityFit(popularity float64, mode string) float64 {
|
||||
popularity = clamp01(popularity)
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "comfort":
|
||||
return clamp01(0.35 + 0.65*popularity)
|
||||
case "discovery":
|
||||
return clamp01(1 - math.Abs(popularity-0.52)*1.25)
|
||||
default:
|
||||
familiarity := popularity
|
||||
midTail := clamp01(1 - math.Abs(popularity-0.62)*1.15)
|
||||
return clamp01(0.55*familiarity + 0.45*midTail)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) explorationScore(track Track, trackVector, tasteVector [featureCount]float64, req RecommendRequest) float64 {
|
||||
target := req.ExplorationTarget
|
||||
if target == 0 {
|
||||
target = 0.22
|
||||
}
|
||||
if strings.EqualFold(req.Mode, "discovery") {
|
||||
target = math.Max(target, 0.34)
|
||||
}
|
||||
if strings.EqualFold(req.Mode, "comfort") {
|
||||
target = math.Min(target, 0.10)
|
||||
}
|
||||
|
||||
d := distance(trackVector, tasteVector)
|
||||
return clamp01(1 - math.Abs(d-target))
|
||||
}
|
||||
|
||||
func (e *Engine) diversify(candidates []Recommendation, limit int, b featureBounds) []Recommendation {
|
||||
if len(candidates) <= limit {
|
||||
return candidates
|
||||
}
|
||||
|
||||
selected := make([]Recommendation, 0, limit)
|
||||
remaining := slices.Clone(candidates)
|
||||
for len(selected) < limit && len(remaining) > 0 {
|
||||
bestIndex := 0
|
||||
bestScore := math.Inf(-1)
|
||||
for i, candidate := range remaining {
|
||||
diversity := minDistanceToSelected(candidate.Track, selected, b)
|
||||
score := e.diversityLambda*candidate.Score + (1-e.diversityLambda)*diversity
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
chosen := remaining[bestIndex]
|
||||
chosen.ScoreBreakdown.Diversity = round(minDistanceToSelected(chosen.Track, selected, b))
|
||||
selected = append(selected, chosen)
|
||||
remaining = append(remaining[:bestIndex], remaining[bestIndex+1:]...)
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
func indexTracks(tracks []Track) map[string]Track {
|
||||
out := make(map[string]Track, len(tracks))
|
||||
for _, track := range tracks {
|
||||
out[track.ID] = track
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func interactionsForUser(interactions []Interaction, userID string) []Interaction {
|
||||
out := make([]Interaction, 0)
|
||||
for _, interaction := range interactions {
|
||||
if interaction.UserID == userID {
|
||||
out = append(out, interaction)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func interactionWeight(interaction Interaction) float64 {
|
||||
if interaction.Weight != 0 {
|
||||
return interaction.Weight
|
||||
}
|
||||
switch interaction.Type {
|
||||
case InteractionLike:
|
||||
return 1
|
||||
case InteractionSave:
|
||||
return 0.9
|
||||
case InteractionPlay:
|
||||
if interaction.CompletedMS > 30_000 {
|
||||
return 0.45
|
||||
}
|
||||
return 0.20
|
||||
case InteractionSkip:
|
||||
return -0.55
|
||||
case InteractionDislike:
|
||||
return -1
|
||||
case InteractionHide:
|
||||
return -1.25
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func timeDecay(now, occurredAt time.Time) float64 {
|
||||
if occurredAt.IsZero() {
|
||||
return 0.7
|
||||
}
|
||||
days := now.Sub(occurredAt).Hours() / 24
|
||||
if days <= 0 {
|
||||
return 1
|
||||
}
|
||||
return math.Exp(-days / 120)
|
||||
}
|
||||
|
||||
func addWeighted(sum *[featureCount]float64, value [featureCount]float64, weight float64) {
|
||||
for i := range featureCount {
|
||||
sum[i] += value[i] * weight
|
||||
}
|
||||
}
|
||||
|
||||
func catalogCentroid(tracks []Track, b featureBounds) [featureCount]float64 {
|
||||
var sum [featureCount]float64
|
||||
count := 0
|
||||
for _, track := range tracks {
|
||||
if !hasAudioFeatures(track.Features) {
|
||||
continue
|
||||
}
|
||||
addWeighted(&sum, normalize(track.Features, b), 1)
|
||||
count++
|
||||
}
|
||||
if count == 0 {
|
||||
return sum
|
||||
}
|
||||
for i := range featureCount {
|
||||
sum[i] /= float64(count)
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
func pearson(a, b map[string]float64) (float64, int) {
|
||||
common := make([]string, 0)
|
||||
for trackID := range a {
|
||||
if _, ok := b[trackID]; ok {
|
||||
common = append(common, trackID)
|
||||
}
|
||||
}
|
||||
if len(common) < 2 {
|
||||
return 0, len(common)
|
||||
}
|
||||
|
||||
var meanA, meanB float64
|
||||
for _, trackID := range common {
|
||||
meanA += a[trackID]
|
||||
meanB += b[trackID]
|
||||
}
|
||||
meanA /= float64(len(common))
|
||||
meanB /= float64(len(common))
|
||||
|
||||
var numerator, denomA, denomB float64
|
||||
for _, trackID := range common {
|
||||
da := a[trackID] - meanA
|
||||
db := b[trackID] - meanB
|
||||
numerator += da * db
|
||||
denomA += da * da
|
||||
denomB += db * db
|
||||
}
|
||||
if denomA == 0 || denomB == 0 {
|
||||
return 0, len(common)
|
||||
}
|
||||
return numerator / (math.Sqrt(denomA) * math.Sqrt(denomB)), len(common)
|
||||
}
|
||||
|
||||
func shouldFilter(track Track, positive map[string]struct{}, controls UserControls, req RecommendRequest) bool {
|
||||
if _, known := positive[track.ID]; known {
|
||||
return true
|
||||
}
|
||||
if req.MinPopularity != nil && track.Popularity < *req.MinPopularity {
|
||||
return true
|
||||
}
|
||||
if req.MaxPopularity != nil && track.Popularity > *req.MaxPopularity {
|
||||
return true
|
||||
}
|
||||
includeExplicit := controls.AllowExplicit
|
||||
if req.IncludeExplicit != nil {
|
||||
includeExplicit = *req.IncludeExplicit
|
||||
}
|
||||
if track.Explicit && !includeExplicit {
|
||||
return true
|
||||
}
|
||||
if contains(controls.ExcludedTracks, track.ID) || contains(controls.PostponedTracks, track.ID) {
|
||||
return true
|
||||
}
|
||||
if contains(controls.ExcludedArtists, track.Artist) {
|
||||
return true
|
||||
}
|
||||
for _, genre := range track.Genres {
|
||||
if containsFold(controls.ExcludedGenres, genre) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mergeControls(controls UserControls, req RecommendRequest) UserControls {
|
||||
if controls.UserID == "" {
|
||||
controls.UserID = req.UserID
|
||||
controls.AllowExplicit = true
|
||||
}
|
||||
controls.ExcludedTracks = append(controls.ExcludedTracks, req.ExcludedTrackIDs...)
|
||||
controls.ExcludedArtists = append(controls.ExcludedArtists, req.ExcludedArtistIDs...)
|
||||
controls.ExcludedGenres = append(controls.ExcludedGenres, req.ExcludedGenres...)
|
||||
return controls
|
||||
}
|
||||
|
||||
func safetyScore(track Track, controls UserControls) float64 {
|
||||
if track.QualityPenalty > 0 {
|
||||
return clamp01(1 - track.QualityPenalty)
|
||||
}
|
||||
if !controls.AllowExplicit && track.Explicit {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func commercialScore(track Track, contentScore float64) float64 {
|
||||
if !track.DiscoveryAllowed || track.CommercialBoost <= 0 || contentScore < 0.72 {
|
||||
return 0
|
||||
}
|
||||
return math.Min(track.CommercialBoost, 0.035)
|
||||
}
|
||||
|
||||
func reason(contentScore, collabScore, explorationScore, metadataScore float64, hasAudioFeatures bool) string {
|
||||
if !hasAudioFeatures && metadataScore >= 0.65 {
|
||||
return "matched by genre, artist, and catalog signals while audio features were limited"
|
||||
}
|
||||
if !hasAudioFeatures {
|
||||
return "balanced catalog match while audio features were limited"
|
||||
}
|
||||
switch {
|
||||
case collabScore >= 0.65:
|
||||
return "listeners with overlapping taste responded strongly to this track"
|
||||
case explorationScore >= 0.82 && contentScore >= 0.58:
|
||||
return "close enough to your taste profile while adding useful variety"
|
||||
case contentScore >= 0.78:
|
||||
return "audio features closely match your current taste profile"
|
||||
default:
|
||||
return "balanced recommendation from catalog, taste, and diversity signals"
|
||||
}
|
||||
}
|
||||
|
||||
func featureExplanation(taste, track [featureCount]float64) map[string]float64 {
|
||||
out := make(map[string]float64, featureCount)
|
||||
for i, name := range featureNames {
|
||||
out[name] = round(1 - math.Abs(taste[i]-track[i]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func minDistanceToSelected(track Track, selected []Recommendation, b featureBounds) float64 {
|
||||
if len(selected) == 0 {
|
||||
return 1
|
||||
}
|
||||
minDistance := math.Inf(1)
|
||||
for _, other := range selected {
|
||||
d := trackDistance(track, other.Track, b)
|
||||
if d < minDistance {
|
||||
minDistance = d
|
||||
}
|
||||
}
|
||||
return clamp01(minDistance)
|
||||
}
|
||||
|
||||
func trackDistance(a, b Track, bounds featureBounds) float64 {
|
||||
if hasAudioFeatures(a.Features) && hasAudioFeatures(b.Features) {
|
||||
return distance(normalize(a.Features, bounds), normalize(b.Features, bounds))
|
||||
}
|
||||
if strings.EqualFold(a.Artist, b.Artist) && a.Artist != "" {
|
||||
return 0.12
|
||||
}
|
||||
if genreOverlap(a.Genres, b.Genres) {
|
||||
return 0.38
|
||||
}
|
||||
return 0.78
|
||||
}
|
||||
|
||||
func genreOverlap(a, b []string) bool {
|
||||
seen := make(map[string]struct{}, len(a))
|
||||
for _, genre := range a {
|
||||
if genre = normalizedToken(genre); genre != "" {
|
||||
seen[genre] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, genre := range b {
|
||||
if _, ok := seen[normalizedToken(genre)]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func topGenres(tracks []Track, byTrackID map[string]Track, interactions []Interaction) map[string]float64 {
|
||||
scores := make(map[string]float64)
|
||||
for _, interaction := range interactions {
|
||||
track, ok := byTrackID[interaction.TrackID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
weight := interactionWeight(interaction)
|
||||
if weight <= 0 {
|
||||
continue
|
||||
}
|
||||
for _, genre := range track.Genres {
|
||||
scores[strings.ToLower(genre)] += weight
|
||||
}
|
||||
}
|
||||
maxScore := 0.0
|
||||
for _, score := range scores {
|
||||
maxScore = math.Max(maxScore, score)
|
||||
}
|
||||
if maxScore == 0 {
|
||||
return scores
|
||||
}
|
||||
for genre, score := range scores {
|
||||
scores[genre] = round(score / maxScore)
|
||||
}
|
||||
return scores
|
||||
}
|
||||
|
||||
func explorationReadiness(confidence float64, interactions []Interaction) float64 {
|
||||
negative := 0.0
|
||||
for _, interaction := range interactions {
|
||||
if interactionWeight(interaction) < 0 {
|
||||
negative++
|
||||
}
|
||||
}
|
||||
friction := 0.0
|
||||
if len(interactions) > 0 {
|
||||
friction = negative / float64(len(interactions))
|
||||
}
|
||||
return clamp01((0.45 + confidence*0.55) * (1 - friction*0.6))
|
||||
}
|
||||
|
||||
func arrayToSlice(value [featureCount]float64) []float64 {
|
||||
out := make([]float64, featureCount)
|
||||
for i := range value {
|
||||
out[i] = round(value[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func contains(values []string, value string) bool {
|
||||
return slices.Contains(values, value)
|
||||
}
|
||||
|
||||
func containsFold(values []string, value string) bool {
|
||||
for _, candidate := range values {
|
||||
if strings.EqualFold(candidate, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func round(value float64) float64 {
|
||||
return math.Round(value*10000) / 10000
|
||||
}
|
||||
|
||||
func ValidateTrack(track Track) error {
|
||||
if strings.TrimSpace(track.ID) == "" {
|
||||
return fmt.Errorf("track id is required")
|
||||
}
|
||||
if strings.TrimSpace(track.Title) == "" {
|
||||
return fmt.Errorf("track title is required")
|
||||
}
|
||||
if strings.TrimSpace(track.Artist) == "" {
|
||||
return fmt.Errorf("track artist is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type testProvider struct {
|
||||
snapshot CatalogSnapshot
|
||||
}
|
||||
|
||||
func (p testProvider) Snapshot(context.Context, string) (CatalogSnapshot, error) {
|
||||
return p.snapshot, nil
|
||||
}
|
||||
|
||||
func TestRecommendBlendsContentAndCollaborativeSignals(t *testing.T) {
|
||||
now := time.Date(2026, 4, 13, 12, 0, 0, 0, time.UTC)
|
||||
engine := NewEngine(EngineConfig{
|
||||
Now: func() time.Time { return now },
|
||||
ContentWeight: 0.44,
|
||||
CollabWeight: 0.28,
|
||||
PopularityWeight: 0.08,
|
||||
ExplorationWeight: 0.20,
|
||||
DiversityLambda: 0.74,
|
||||
})
|
||||
|
||||
tracks := []Track{
|
||||
track("liked", "Known Good", "A", []string{"synth"}, 0.7, AudioFeatures{Danceability: 0.8, Energy: 0.8, Loudness: -5, Valence: 0.7, Tempo: 120, TimeSignature: 4, Key: 1, Mode: 1}),
|
||||
track("neighbor", "Neighbor Pick", "B", []string{"synth"}, 0.6, AudioFeatures{Danceability: 0.76, Energy: 0.77, Loudness: -6, Valence: 0.66, Tempo: 121, TimeSignature: 4, Key: 2, Mode: 1}),
|
||||
track("far", "Far Away", "C", []string{"folk"}, 0.5, AudioFeatures{Danceability: 0.2, Energy: 0.2, Loudness: -18, Acousticness: 0.9, Valence: 0.3, Tempo: 80, TimeSignature: 3, Key: 9, Mode: 0}),
|
||||
}
|
||||
|
||||
recs, profile, err := engine.Recommend(context.Background(), testProvider{snapshot: CatalogSnapshot{
|
||||
Tracks: tracks,
|
||||
Interactions: []Interaction{
|
||||
{UserID: "u1", TrackID: "liked", Type: InteractionLike, OccurredAt: now.Add(-time.Hour)},
|
||||
{UserID: "u1", TrackID: "far", Type: InteractionSkip, OccurredAt: now.Add(-2 * time.Hour)},
|
||||
{UserID: "n1", TrackID: "liked", Type: InteractionLike, OccurredAt: now.Add(-3 * time.Hour)},
|
||||
{UserID: "n1", TrackID: "far", Type: InteractionSkip, OccurredAt: now.Add(-4 * time.Hour)},
|
||||
{UserID: "n1", TrackID: "neighbor", Type: InteractionLike, OccurredAt: now.Add(-5 * time.Hour)},
|
||||
},
|
||||
Controls: UserControls{UserID: "u1", AllowExplicit: true},
|
||||
}}, RecommendRequest{UserID: "u1", Limit: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("recommend: %v", err)
|
||||
}
|
||||
if len(recs) == 0 {
|
||||
t.Fatal("expected recommendations")
|
||||
}
|
||||
if recs[0].Track.ID != "neighbor" {
|
||||
t.Fatalf("expected neighbor track first, got %q", recs[0].Track.ID)
|
||||
}
|
||||
if profile.Confidence <= 0 {
|
||||
t.Fatalf("expected non-zero confidence, got %v", profile.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendRespectsControls(t *testing.T) {
|
||||
now := time.Date(2026, 4, 13, 12, 0, 0, 0, time.UTC)
|
||||
engine := NewEngine(EngineConfig{Now: func() time.Time { return now }, ContentWeight: 1, DiversityLambda: 0.8})
|
||||
explicit := track("explicit", "Explicit", "A", []string{"pop"}, 0.5, AudioFeatures{Danceability: 0.7, Energy: 0.7, Loudness: -6, Valence: 0.7, Tempo: 120, TimeSignature: 4})
|
||||
explicit.Explicit = true
|
||||
clean := track("clean", "Clean", "A", []string{"pop"}, 0.5, AudioFeatures{Danceability: 0.69, Energy: 0.71, Loudness: -6, Valence: 0.68, Tempo: 121, TimeSignature: 4})
|
||||
|
||||
recs, _, err := engine.Recommend(context.Background(), testProvider{snapshot: CatalogSnapshot{
|
||||
Tracks: []Track{
|
||||
track("seed", "Seed", "A", []string{"pop"}, 0.5, AudioFeatures{Danceability: 0.7, Energy: 0.7, Loudness: -6, Valence: 0.7, Tempo: 120, TimeSignature: 4}),
|
||||
explicit,
|
||||
clean,
|
||||
},
|
||||
Interactions: []Interaction{{UserID: "u1", TrackID: "seed", Type: InteractionLike, OccurredAt: now}},
|
||||
Controls: UserControls{UserID: "u1", AllowExplicit: false},
|
||||
}}, RecommendRequest{UserID: "u1", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("recommend: %v", err)
|
||||
}
|
||||
for _, rec := range recs {
|
||||
if rec.Track.ID == "explicit" {
|
||||
t.Fatal("explicit track should be filtered")
|
||||
}
|
||||
}
|
||||
if len(recs) != 1 || recs[0].Track.ID != "clean" {
|
||||
t.Fatalf("expected only clean track, got %#v", recs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendUsesMetadataWhenAudioFeaturesAreMissing(t *testing.T) {
|
||||
now := time.Date(2026, 4, 13, 12, 0, 0, 0, time.UTC)
|
||||
engine := NewEngine(EngineConfig{Now: func() time.Time { return now }, ContentWeight: 1, DiversityLambda: 0.85})
|
||||
|
||||
recs, _, err := engine.Recommend(context.Background(), testProvider{snapshot: CatalogSnapshot{
|
||||
Tracks: []Track{
|
||||
track("seed", "Seed", "Seed Artist", []string{"synthpop"}, 0.5, AudioFeatures{}),
|
||||
track("genre-match", "Genre Match", "Other Artist", []string{"synthpop"}, 0.5, AudioFeatures{}),
|
||||
track("unrelated", "Unrelated", "Far Artist", []string{"folk"}, 0.5, AudioFeatures{}),
|
||||
},
|
||||
Controls: UserControls{UserID: "u1", AllowExplicit: true},
|
||||
}}, RecommendRequest{UserID: "u1", SeedTrackIDs: []string{"seed"}, Limit: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("recommend: %v", err)
|
||||
}
|
||||
if len(recs) == 0 {
|
||||
t.Fatal("expected recommendations")
|
||||
}
|
||||
if recs[0].Track.ID != "genre-match" {
|
||||
t.Fatalf("expected metadata genre match first, got %q", recs[0].Track.ID)
|
||||
}
|
||||
for _, rec := range recs {
|
||||
if rec.Track.ID == "seed" {
|
||||
t.Fatal("seed track should not be recommended back")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendPenalizesSkippedNeighborhoods(t *testing.T) {
|
||||
now := time.Date(2026, 4, 13, 12, 0, 0, 0, time.UTC)
|
||||
engine := NewEngine(EngineConfig{
|
||||
Now: func() time.Time { return now },
|
||||
ContentWeight: 0.74,
|
||||
PopularityWeight: 0.08,
|
||||
ExplorationWeight: 0.18,
|
||||
DiversityLambda: 0.9,
|
||||
})
|
||||
|
||||
audio := AudioFeatures{Danceability: 0.74, Energy: 0.76, Loudness: -5, Speechiness: 0.05, Acousticness: 0.12, Instrumentalness: 0.04, Liveness: 0.12, Valence: 0.66, Tempo: 124, TimeSignature: 4, Key: 2, Mode: 1}
|
||||
recs, _, err := engine.Recommend(context.Background(), testProvider{snapshot: CatalogSnapshot{
|
||||
Tracks: []Track{
|
||||
track("liked", "Liked", "A", []string{"dance"}, 0.7, audio),
|
||||
track("skipped", "Skipped", "B", []string{"metal"}, 0.7, audio),
|
||||
track("penalized", "Penalized", "C", []string{"metal"}, 0.7, audio),
|
||||
track("safe", "Safe", "D", []string{"dance"}, 0.62, AudioFeatures{Danceability: 0.72, Energy: 0.74, Loudness: -6, Speechiness: 0.05, Acousticness: 0.14, Instrumentalness: 0.05, Liveness: 0.1, Valence: 0.64, Tempo: 125, TimeSignature: 4, Key: 3, Mode: 1}),
|
||||
},
|
||||
Interactions: []Interaction{
|
||||
{UserID: "u1", TrackID: "liked", Type: InteractionLike, OccurredAt: now.Add(-time.Hour)},
|
||||
{UserID: "u1", TrackID: "skipped", Type: InteractionSkip, OccurredAt: now.Add(-30 * time.Minute)},
|
||||
},
|
||||
Controls: UserControls{UserID: "u1", AllowExplicit: true},
|
||||
}}, RecommendRequest{UserID: "u1", Limit: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("recommend: %v", err)
|
||||
}
|
||||
if len(recs) == 0 {
|
||||
t.Fatal("expected recommendations")
|
||||
}
|
||||
if recs[0].Track.ID != "safe" {
|
||||
t.Fatalf("expected non-skipped neighborhood first, got %q", recs[0].Track.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func track(id, title, artist string, genres []string, popularity float64, features AudioFeatures) Track {
|
||||
return Track{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Artist: artist,
|
||||
Genres: genres,
|
||||
Popularity: popularity,
|
||||
Features: features,
|
||||
DiscoveryAllowed: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package recommendation
|
||||
|
||||
import "time"
|
||||
|
||||
type Track struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Album string `json:"album,omitempty"`
|
||||
Genres []string `json:"genres,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
DurationMS int `json:"duration_ms,omitempty"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
Explicit bool `json:"explicit"`
|
||||
Features AudioFeatures `json:"features"`
|
||||
External map[string]string `json:"external,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CommercialBoost float64 `json:"commercial_boost,omitempty"`
|
||||
QualityPenalty float64 `json:"quality_penalty,omitempty"`
|
||||
DiscoveryAllowed bool `json:"discovery_allowed"`
|
||||
}
|
||||
|
||||
type AudioFeatures struct {
|
||||
Danceability float64 `json:"danceability"`
|
||||
Energy float64 `json:"energy"`
|
||||
Loudness float64 `json:"loudness"`
|
||||
Speechiness float64 `json:"speechiness"`
|
||||
Acousticness float64 `json:"acousticness"`
|
||||
Instrumentalness float64 `json:"instrumentalness"`
|
||||
Liveness float64 `json:"liveness"`
|
||||
Valence float64 `json:"valence"`
|
||||
Tempo float64 `json:"tempo"`
|
||||
TimeSignature float64 `json:"time_signature"`
|
||||
Key float64 `json:"key"`
|
||||
Mode float64 `json:"mode"`
|
||||
}
|
||||
|
||||
type InteractionType string
|
||||
|
||||
const (
|
||||
InteractionPlay InteractionType = "play"
|
||||
InteractionSkip InteractionType = "skip"
|
||||
InteractionLike InteractionType = "like"
|
||||
InteractionDislike InteractionType = "dislike"
|
||||
InteractionSave InteractionType = "save"
|
||||
InteractionHide InteractionType = "hide"
|
||||
)
|
||||
|
||||
type Interaction struct {
|
||||
UserID string `json:"user_id"`
|
||||
TrackID string `json:"track_id"`
|
||||
Type InteractionType `json:"type"`
|
||||
Weight float64 `json:"weight,omitempty"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Context Context `json:"context,omitempty"`
|
||||
CompletedMS int `json:"completed_ms,omitempty"`
|
||||
}
|
||||
|
||||
type Context struct {
|
||||
Locale string `json:"locale,omitempty"`
|
||||
Device string `json:"device,omitempty"`
|
||||
TimeOfDay string `json:"time_of_day,omitempty"`
|
||||
Activity string `json:"activity,omitempty"`
|
||||
Mood string `json:"mood,omitempty"`
|
||||
}
|
||||
|
||||
type UserControls struct {
|
||||
UserID string `json:"user_id"`
|
||||
AllowExplicit bool `json:"allow_explicit"`
|
||||
ExcludedTracks []string `json:"excluded_tracks,omitempty"`
|
||||
ExcludedArtists []string `json:"excluded_artists,omitempty"`
|
||||
ExcludedGenres []string `json:"excluded_genres,omitempty"`
|
||||
PostponedTracks []string `json:"postponed_tracks,omitempty"`
|
||||
}
|
||||
|
||||
type RecommendRequest struct {
|
||||
UserID string `json:"user_id"`
|
||||
Limit int `json:"limit"`
|
||||
SeedTrackIDs []string `json:"seed_track_ids,omitempty"`
|
||||
FeatureTargets *AudioFeatures `json:"feature_targets,omitempty"`
|
||||
Context Context `json:"context,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
ExplorationTarget float64 `json:"exploration_target,omitempty"`
|
||||
MinPopularity *float64 `json:"min_popularity,omitempty"`
|
||||
MaxPopularity *float64 `json:"max_popularity,omitempty"`
|
||||
IncludeExplicit *bool `json:"include_explicit,omitempty"`
|
||||
ExcludedTrackIDs []string `json:"excluded_track_ids,omitempty"`
|
||||
ExcludedArtistIDs []string `json:"excluded_artist_ids,omitempty"`
|
||||
ExcludedGenres []string `json:"excluded_genres,omitempty"`
|
||||
}
|
||||
|
||||
type Recommendation struct {
|
||||
Track Track `json:"track"`
|
||||
Score float64 `json:"score"`
|
||||
Rank int `json:"rank"`
|
||||
Reason string `json:"reason"`
|
||||
ScoreBreakdown ScoreBreakdown `json:"score_breakdown"`
|
||||
Explanation map[string]float64 `json:"explanation"`
|
||||
}
|
||||
|
||||
type ScoreBreakdown struct {
|
||||
Content float64 `json:"content"`
|
||||
Collaborative float64 `json:"collaborative"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
Exploration float64 `json:"exploration"`
|
||||
Diversity float64 `json:"diversity"`
|
||||
Safety float64 `json:"safety"`
|
||||
Commercial float64 `json:"commercial"`
|
||||
Final float64 `json:"final"`
|
||||
}
|
||||
|
||||
type TasteProfile struct {
|
||||
UserID string `json:"user_id"`
|
||||
Vector []float64 `json:"vector"`
|
||||
TopGenres map[string]float64 `json:"top_genres"`
|
||||
InteractionCount int `json:"interaction_count"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
ExplorationReadiness float64 `json:"exploration_readiness"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CatalogSnapshot struct {
|
||||
Tracks []Track
|
||||
Interactions []Interaction
|
||||
Controls UserControls
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package recommendation
|
||||
|
||||
import "math"
|
||||
|
||||
const featureCount = 12
|
||||
|
||||
var featureNames = []string{
|
||||
"danceability",
|
||||
"energy",
|
||||
"loudness",
|
||||
"speechiness",
|
||||
"acousticness",
|
||||
"instrumentalness",
|
||||
"liveness",
|
||||
"valence",
|
||||
"tempo",
|
||||
"time_signature",
|
||||
"key",
|
||||
"mode",
|
||||
}
|
||||
|
||||
type featureSpec struct {
|
||||
min float64
|
||||
max float64
|
||||
weight float64
|
||||
}
|
||||
|
||||
var featureSpecs = [featureCount]featureSpec{
|
||||
{min: 0, max: 1, weight: 1.12}, // danceability
|
||||
{min: 0, max: 1, weight: 1.18}, // energy
|
||||
{min: -60, max: 0, weight: 0.78}, // loudness
|
||||
{min: 0, max: 1, weight: 0.72}, // speechiness
|
||||
{min: 0, max: 1, weight: 1.02}, // acousticness
|
||||
{min: 0, max: 1, weight: 0.82}, // instrumentalness
|
||||
{min: 0, max: 1, weight: 0.44}, // liveness
|
||||
{min: 0, max: 1, weight: 1.08}, // valence
|
||||
{min: 40, max: 220, weight: 0.92}, // tempo
|
||||
{min: 1, max: 7, weight: 0.22}, // time signature
|
||||
{min: 0, max: 11, weight: 0.20}, // key
|
||||
{min: 0, max: 1, weight: 0.16}, // mode
|
||||
}
|
||||
|
||||
type featureBounds struct {
|
||||
min [featureCount]float64
|
||||
max [featureCount]float64
|
||||
}
|
||||
|
||||
func vector(features AudioFeatures) [featureCount]float64 {
|
||||
return [featureCount]float64{
|
||||
features.Danceability,
|
||||
features.Energy,
|
||||
features.Loudness,
|
||||
features.Speechiness,
|
||||
features.Acousticness,
|
||||
features.Instrumentalness,
|
||||
features.Liveness,
|
||||
features.Valence,
|
||||
features.Tempo,
|
||||
features.TimeSignature,
|
||||
features.Key,
|
||||
features.Mode,
|
||||
}
|
||||
}
|
||||
|
||||
func bounds(tracks []Track) featureBounds {
|
||||
var b featureBounds
|
||||
for i := range featureCount {
|
||||
b.min[i] = featureSpecs[i].min
|
||||
b.max[i] = featureSpecs[i].max
|
||||
}
|
||||
|
||||
for _, track := range tracks {
|
||||
if !hasAudioFeatures(track.Features) {
|
||||
continue
|
||||
}
|
||||
v := vector(track.Features)
|
||||
for i, value := range v {
|
||||
if value < b.min[i] {
|
||||
b.min[i] = value
|
||||
}
|
||||
if value > b.max[i] {
|
||||
b.max[i] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i := range featureCount {
|
||||
if math.IsInf(b.min[i], 0) || math.IsInf(b.max[i], 0) || b.min[i] == b.max[i] {
|
||||
b.min[i] = 0
|
||||
b.max[i] = 1
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func normalize(features AudioFeatures, b featureBounds) [featureCount]float64 {
|
||||
raw := vector(features)
|
||||
var out [featureCount]float64
|
||||
for i, value := range raw {
|
||||
denominator := b.max[i] - b.min[i]
|
||||
if denominator == 0 {
|
||||
out[i] = 0
|
||||
continue
|
||||
}
|
||||
out[i] = clamp01((value - b.min[i]) / denominator)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cosine(a, b [featureCount]float64) float64 {
|
||||
var dot, normA, normB float64
|
||||
for i := range featureCount {
|
||||
weight := featureSpecs[i].weight
|
||||
dot += weight * a[i] * b[i]
|
||||
normA += weight * a[i] * a[i]
|
||||
normB += weight * b[i] * b[i]
|
||||
}
|
||||
if normA == 0 || normB == 0 {
|
||||
return 0
|
||||
}
|
||||
return dot / (math.Sqrt(normA) * math.Sqrt(normB))
|
||||
}
|
||||
|
||||
func distance(a, b [featureCount]float64) float64 {
|
||||
return 1 - cosine(a, b)
|
||||
}
|
||||
|
||||
func clamp01(value float64) float64 {
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
if value > 1 {
|
||||
return 1
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func hasAudioFeatures(features AudioFeatures) bool {
|
||||
raw := vector(features)
|
||||
nonZero := 0
|
||||
for _, value := range raw {
|
||||
if value != 0 {
|
||||
nonZero++
|
||||
}
|
||||
}
|
||||
return nonZero >= 4 && features.Tempo > 0 && features.TimeSignature > 0
|
||||
}
|
||||
Reference in New Issue
Block a user