f0c08e7326
Milestone #160 Opt 2b (mood half of the era+mood option). A fourth taste facet alongside artists + genre tags + eras: signed weights over canonical mood buckets (melancholic / energetic / chill / …) derived from a track's enriched folksonomy tags (#1490). - internal/mood: shared vocabulary — Of(tags) maps folksonomy tags to canonical mood buckets (synonyms collapse). Imported by both the taste builder and the scorer so a track's mood is derived identically. - Migration 0047: taste_profile_moods table + taste_tuning.mood_scale (DEFAULT 0.5). - Build side (internal/taste): Config.MoodScale ([0,1] damper, mirrors EraScale); accumulate folds each play/like's mood buckets at base*MoodScale; persist atomic-replaces the mood rows. - Scorer (internal/recommendation): TasteProfile gains a mood term (own tanh scale + additive 0.12 share, so it never weakens the existing signal when a track has no mood tags). Match now takes the candidate's mood buckets; loaded per candidate (ListTrackTagsForTracks → mood.Of) in the primary similarity loader only — the near-whole-library fallback pool passes nil (mood → 0) to avoid a full-library tag scan. - Tuning lab: mood_scale threaded through recsettings + admin API + web card ("Mood weight" row) + Go/web tests. Coverage is partial (grows with tag enrichment; richer once Last.fm is keyed), so mood is a supplement — neutral for tracks with no mood tags. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
59 lines
2.5 KiB
Go
59 lines
2.5 KiB
Go
// Package mood maps folksonomy tags (from the track_tags cache, #1490) to a
|
|
// small set of canonical mood buckets (#1534, milestone #160 Opt 2b). Both the
|
|
// taste-profile builder and the recommendation scorer classify a track's tags
|
|
// through Of() so a track's mood is derived the same way on both sides.
|
|
//
|
|
// The vocabulary is intentionally narrow: only words that clearly denote mood
|
|
// (not genre). A track with no mood-word tags contributes nothing — the mood
|
|
// facet is a supplement whose coverage grows with folksonomy enrichment
|
|
// (thin on MusicBrainz-only, richer once Last.fm is keyed).
|
|
package mood
|
|
|
|
import "strings"
|
|
|
|
// vocab maps a lowercased folksonomy tag to its canonical mood bucket. Several
|
|
// synonyms collapse to one bucket so "sad" / "melancholy" / "melancholic" all
|
|
// score the same dimension.
|
|
var vocab = map[string]string{
|
|
// melancholic
|
|
"melancholic": "melancholic", "melancholy": "melancholic", "sad": "melancholic",
|
|
"sombre": "melancholic", "somber": "melancholic", "wistful": "melancholic",
|
|
"bittersweet": "melancholic", "mournful": "melancholic",
|
|
// energetic
|
|
"energetic": "energetic", "energy": "energetic", "upbeat": "energetic",
|
|
"uplifting": "energetic", "party": "energetic", "anthemic": "energetic",
|
|
// chill
|
|
"chill": "chill", "chillout": "chill", "chilled": "chill", "mellow": "chill",
|
|
"relaxing": "chill", "relaxed": "chill", "calm": "chill", "laid-back": "chill",
|
|
"laidback": "chill", "soothing": "chill",
|
|
// aggressive
|
|
"aggressive": "aggressive", "angry": "aggressive", "intense": "aggressive",
|
|
"heavy": "aggressive", "brutal": "aggressive",
|
|
// dark
|
|
"dark": "dark", "moody": "dark", "brooding": "dark", "haunting": "dark", "eerie": "dark",
|
|
// dreamy
|
|
"dreamy": "dreamy", "ethereal": "dreamy", "atmospheric": "dreamy",
|
|
"ambient": "dreamy", "hypnotic": "dreamy",
|
|
// happy
|
|
"happy": "happy", "cheerful": "happy", "feel good": "happy", "feel-good": "happy",
|
|
"feelgood": "happy", "joyful": "happy", "fun": "happy",
|
|
// romantic
|
|
"romantic": "romantic", "sensual": "romantic", "sexy": "romantic", "sentimental": "romantic",
|
|
}
|
|
|
|
// Of returns the distinct canonical mood buckets present in tags, lowercasing +
|
|
// trimming each tag before lookup. Returns nil when no tag maps to a mood, so
|
|
// callers get no mood signal for a track with only genre/descriptive tags.
|
|
func Of(tags []string) []string {
|
|
var out []string
|
|
seen := make(map[string]bool)
|
|
for _, t := range tags {
|
|
key := strings.ToLower(strings.TrimSpace(t))
|
|
if m, ok := vocab[key]; ok && !seen[m] {
|
|
seen[m] = true
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|