/** * Mood Bucket Service * * Handles pre-computed mood assignments for fast mood mix generation. * Tracks are assigned to mood buckets during audio analysis, enabling * instant mood mix generation through simple database lookups. */ import { prisma } from "../utils/db"; // Mood configuration with scoring rules // Primary = uses ML mood predictions (enhanced mode) // Fallback = uses basic audio features (standard mode) export const MOOD_CONFIG = { happy: { name: "Happy & Upbeat", color: "from-yellow-400 to-orange-500", icon: "Smile", // Primary: ML mood prediction primary: { moodHappy: { min: 0.5 }, moodSad: { max: 0.4 } }, // Fallback: basic audio features fallback: { valence: { min: 0.6 }, energy: { min: 0.5 } }, }, sad: { name: "Melancholic", color: "from-blue-600 to-indigo-700", icon: "CloudRain", primary: { moodSad: { min: 0.5 }, moodHappy: { max: 0.4 } }, fallback: { valence: { max: 0.35 }, keyScale: "minor" }, }, chill: { name: "Chill & Relaxed", color: "from-teal-400 to-cyan-500", icon: "Wind", primary: { moodRelaxed: { min: 0.5 }, moodAggressive: { max: 0.3 } }, fallback: { energy: { max: 0.5 }, arousal: { max: 0.5 } }, }, energetic: { name: "High Energy", color: "from-red-500 to-orange-600", icon: "Zap", primary: { arousal: { min: 0.6 }, energy: { min: 0.7 } }, fallback: { bpm: { min: 120 }, energy: { min: 0.7 } }, }, party: { name: "Dance Party", color: "from-pink-500 to-rose-600", icon: "PartyPopper", primary: { moodParty: { min: 0.5 }, danceability: { min: 0.6 } }, fallback: { danceability: { min: 0.7 }, energy: { min: 0.6 } }, }, focus: { name: "Focus Mode", color: "from-purple-600 to-violet-700", icon: "Brain", primary: { instrumentalness: { min: 0.5 }, moodRelaxed: { min: 0.3 } }, fallback: { instrumentalness: { min: 0.5 }, energy: { min: 0.2, max: 0.6 }, }, }, melancholy: { name: "Deep Feels", color: "from-gray-700 to-slate-800", icon: "Moon", primary: { moodSad: { min: 0.4 }, valence: { max: 0.4 } }, fallback: { valence: { max: 0.35 }, keyScale: "minor" }, }, aggressive: { name: "Intense", color: "from-red-700 to-gray-900", icon: "Flame", primary: { moodAggressive: { min: 0.5 } }, fallback: { energy: { min: 0.8 }, arousal: { min: 0.7 } }, }, acoustic: { name: "Acoustic Vibes", color: "from-amber-500 to-yellow-600", icon: "Guitar", primary: { moodAcoustic: { min: 0.5 }, moodElectronic: { max: 0.4 } }, fallback: { acousticness: { min: 0.6 }, energy: { min: 0.3, max: 0.6 }, }, }, } as const; export type MoodType = keyof typeof MOOD_CONFIG; export const VALID_MOODS = Object.keys(MOOD_CONFIG) as MoodType[]; // Mood gradient colors for mix display const MOOD_GRADIENTS: Record = { happy: "linear-gradient(to bottom, rgba(217, 119, 6, 0.5), rgba(161, 98, 7, 0.4), rgba(68, 64, 60, 0.4))", sad: "linear-gradient(to bottom, rgba(30, 58, 138, 0.6), rgba(88, 28, 135, 0.5), rgba(15, 23, 42, 0.4))", chill: "linear-gradient(to bottom, rgba(17, 94, 89, 0.6), rgba(22, 78, 99, 0.5), rgba(15, 23, 42, 0.4))", energetic: "linear-gradient(to bottom, rgba(153, 27, 27, 0.6), rgba(124, 45, 18, 0.5), rgba(68, 64, 60, 0.4))", party: "linear-gradient(to bottom, rgba(162, 28, 175, 0.6), rgba(131, 24, 67, 0.5), rgba(59, 7, 100, 0.4))", focus: "linear-gradient(to bottom, rgba(91, 33, 182, 0.6), rgba(88, 28, 135, 0.5), rgba(15, 23, 42, 0.4))", melancholy: "linear-gradient(to bottom, rgba(51, 65, 85, 0.6), rgba(30, 58, 138, 0.5), rgba(17, 24, 39, 0.4))", aggressive: "linear-gradient(to bottom, rgba(69, 10, 10, 0.7), rgba(17, 24, 39, 0.6), rgba(0, 0, 0, 0.5))", acoustic: "linear-gradient(to bottom, rgba(146, 64, 14, 0.6), rgba(124, 45, 18, 0.5), rgba(68, 64, 60, 0.4))", }; interface TrackWithAnalysis { id: string; analysisMode: string | null; moodHappy: number | null; moodSad: number | null; moodRelaxed: number | null; moodAggressive: number | null; moodParty: number | null; moodAcoustic: number | null; moodElectronic: number | null; valence: number | null; energy: number | null; arousal: number | null; danceability: number | null; acousticness: number | null; instrumentalness: number | null; bpm: number | null; keyScale: string | null; } export class MoodBucketService { /** * Calculate mood scores for a track and assign to appropriate buckets * Called after audio analysis completes * Returns array of mood names the track was assigned to */ async assignTrackToMoods(trackId: string): Promise { const track = await prisma.track.findUnique({ where: { id: trackId }, select: { id: true, analysisStatus: true, analysisMode: true, moodHappy: true, moodSad: true, moodRelaxed: true, moodAggressive: true, moodParty: true, moodAcoustic: true, moodElectronic: true, valence: true, energy: true, arousal: true, danceability: true, acousticness: true, instrumentalness: true, bpm: true, keyScale: true, }, }); if (!track || track.analysisStatus !== "completed") { console.log( `[MoodBucket] Track ${trackId} not analyzed yet, skipping` ); return []; } const moodScores = this.calculateMoodScores(track); // Upsert mood bucket entries for each mood with score > 0 const upsertPromises = Object.entries(moodScores) .filter(([_, score]) => score > 0) .map(([mood, score]) => prisma.moodBucket.upsert({ where: { trackId_mood: { trackId, mood }, }, create: { trackId, mood, score, }, update: { score, }, }) ); // Also delete mood buckets where score dropped to 0 const deletePromises = Object.entries(moodScores) .filter(([_, score]) => score === 0) .map(([mood]) => prisma.moodBucket.deleteMany({ where: { trackId, mood }, }) ); await Promise.all([...upsertPromises, ...deletePromises]); const assignedMoods = Object.entries(moodScores) .filter(([_, score]) => score > 0) .map(([mood]) => mood); console.log( `[MoodBucket] Track ${trackId} assigned to moods: ${ assignedMoods.join(", ") || "none" }` ); return assignedMoods; } /** * Calculate mood scores for a track based on its audio features * Returns a score 0-1 for each mood (0 = not matching, 1 = perfect match) */ calculateMoodScores(track: TrackWithAnalysis): Record { const isEnhanced = track.analysisMode === "enhanced"; const scores: Record = { happy: 0, sad: 0, chill: 0, energetic: 0, party: 0, focus: 0, melancholy: 0, aggressive: 0, acoustic: 0, }; for (const [mood, config] of Object.entries(MOOD_CONFIG)) { const rules = isEnhanced ? config.primary : config.fallback; const score = this.evaluateMoodRules(track, rules); scores[mood as MoodType] = score; } return scores; } /** * Evaluate mood rules against track features * Returns a score 0-1 based on how well the track matches the rules */ private evaluateMoodRules( track: TrackWithAnalysis, rules: Record ): number { let totalScore = 0; let ruleCount = 0; for (const [field, constraints] of Object.entries(rules)) { const value = track[field as keyof TrackWithAnalysis]; // Skip if value is null if (value === null || value === undefined) { continue; } ruleCount++; // Handle string equality (e.g., keyScale: "minor") if (typeof constraints === "string") { totalScore += value === constraints ? 1 : 0; continue; } // Handle numeric range constraints const numValue = value as number; const { min, max } = constraints as { min?: number; max?: number }; // Calculate how well the value matches the constraint let fieldScore = 0; if (min !== undefined && max !== undefined) { // Range constraint - value should be between min and max if (numValue >= min && numValue <= max) { // Perfect match in range fieldScore = 1; } else if (numValue < min) { // Below range - linearly decrease score fieldScore = Math.max(0, 1 - (min - numValue) * 2); } else { // Above range - linearly decrease score fieldScore = Math.max(0, 1 - (numValue - max) * 2); } } else if (min !== undefined) { // Minimum constraint - higher is better if (numValue >= min) { // Score increases with value above threshold fieldScore = Math.min(1, 0.5 + (numValue - min) * 0.5); } else { // Below minimum - partial credit fieldScore = Math.max(0, (numValue / min) * 0.5); } } else if (max !== undefined) { // Maximum constraint - lower is better if (numValue <= max) { // Score increases as value decreases below threshold fieldScore = Math.min(1, 0.5 + (max - numValue) * 0.5); } else { // Above maximum - partial credit fieldScore = Math.max( 0, ((1 - numValue) / (1 - max)) * 0.5 ); } } totalScore += fieldScore; } // No rules matched (missing data) if (ruleCount === 0) return 0; // Average score across all rules, with minimum threshold const avgScore = totalScore / ruleCount; // Only assign to mood if score is above 0.5 threshold return avgScore >= 0.5 ? avgScore : 0; } /** * Get mood presets with track counts for the UI */ async getMoodPresets(): Promise< { id: string; name: string; color: string; icon: string; trackCount: number; }[] > { // Count tracks per mood in parallel const countPromises = VALID_MOODS.map(async (mood) => { const count = await prisma.moodBucket.count({ where: { mood, score: { gte: 0.5 } }, }); const config = MOOD_CONFIG[mood]; return { id: mood, name: config.name, color: config.color, icon: config.icon, trackCount: count, }; }); return Promise.all(countPromises); } /** * Get a mood mix for a specific mood * Fast lookup from pre-computed MoodBucket table */ async getMoodMix( mood: MoodType, limit: number = 15 ): Promise<{ id: string; mood: string; name: string; description: string; trackIds: string[]; coverUrls: string[]; trackCount: number; color: string; } | null> { if (!VALID_MOODS.includes(mood)) { throw new Error(`Invalid mood: ${mood}`); } const config = MOOD_CONFIG[mood]; // Get top tracks for this mood, randomly sampled // First get IDs with high scores, then randomly select const moodBuckets = await prisma.moodBucket.findMany({ where: { mood, score: { gte: 0.5 } }, select: { trackId: true, score: true }, orderBy: { score: "desc" }, take: 100, // Pool to sample from }); if (moodBuckets.length < 8) { console.log( `[MoodBucket] Not enough tracks for mood ${mood}: ${moodBuckets.length}` ); return null; } // Randomly sample from the pool const shuffled = [...moodBuckets].sort(() => Math.random() - 0.5); const selectedIds = shuffled.slice(0, limit).map((b) => b.trackId); // Get cover URLs for the selected tracks const tracks = await prisma.track.findMany({ where: { id: { in: selectedIds } }, select: { id: true, album: { select: { coverUrl: true } }, }, }); // Preserve order of selectedIds const orderedTracks = selectedIds .map((id) => tracks.find((t) => t.id === id)) .filter(Boolean); const coverUrls = orderedTracks .filter((t) => t?.album.coverUrl) .slice(0, 4) .map((t) => t!.album.coverUrl!); const timestamp = Date.now(); return { id: `mood-${mood}-${timestamp}`, mood, name: `${config.name} Mix`, description: `Tracks that match your ${config.name.toLowerCase()} vibe`, trackIds: orderedTracks.map((t) => t!.id), coverUrls, trackCount: orderedTracks.length, color: MOOD_GRADIENTS[mood], }; } /** * Save a mood mix as the user's active mood mix * Returns the saved mix for immediate UI update */ async saveUserMoodMix( userId: string, mood: MoodType, limit: number = 15 ): Promise<{ id: string; mood: string; name: string; description: string; trackIds: string[]; coverUrls: string[]; trackCount: number; color: string; generatedAt: string; } | null> { // Generate a fresh mix const mix = await this.getMoodMix(mood, limit); if (!mix) return null; const config = MOOD_CONFIG[mood]; const generatedAt = new Date(); // Upsert the user's mood mix await prisma.userMoodMix.upsert({ where: { userId }, create: { userId, mood, trackIds: mix.trackIds, coverUrls: mix.coverUrls, generatedAt, }, update: { mood, trackIds: mix.trackIds, coverUrls: mix.coverUrls, generatedAt, }, }); console.log( `[MoodBucket] Saved ${mood} mix for user ${userId} (${mix.trackCount} tracks)` ); // Return with user-specific naming return { id: `your-mood-mix-${generatedAt.getTime()}`, mood, name: `Your ${config.name} Mix`, description: `Based on your ${config.name.toLowerCase()} preferences`, trackIds: mix.trackIds, coverUrls: mix.coverUrls, trackCount: mix.trackCount, color: MOOD_GRADIENTS[mood], generatedAt: generatedAt.toISOString(), }; } /** * Get user's current saved mood mix for display on home page */ async getUserMoodMix(userId: string): Promise<{ id: string; type: string; mood: string; name: string; description: string; trackIds: string[]; coverUrls: string[]; trackCount: number; color: string; } | null> { const userMix = await prisma.userMoodMix.findUnique({ where: { userId }, }); if (!userMix) return null; const mood = userMix.mood as MoodType; if (!VALID_MOODS.includes(mood)) return null; const config = MOOD_CONFIG[mood]; return { id: `your-mood-mix-${userMix.generatedAt.getTime()}`, type: "mood", mood, name: `Your ${config.name} Mix`, description: `Based on your ${config.name.toLowerCase()} preferences`, trackIds: userMix.trackIds, coverUrls: userMix.coverUrls, trackCount: userMix.trackIds.length, color: MOOD_GRADIENTS[mood], }; } /** * Backfill mood buckets for all analyzed tracks * Used for initial population or after schema changes */ async backfillAllTracks( batchSize: number = 100 ): Promise<{ processed: number; assigned: number }> { let processed = 0; let assigned = 0; let skip = 0; console.log("[MoodBucket] Starting backfill of all analyzed tracks..."); while (true) { const tracks = await prisma.track.findMany({ where: { analysisStatus: "completed" }, select: { id: true, analysisMode: true, moodHappy: true, moodSad: true, moodRelaxed: true, moodAggressive: true, moodParty: true, moodAcoustic: true, moodElectronic: true, valence: true, energy: true, arousal: true, danceability: true, acousticness: true, instrumentalness: true, bpm: true, keyScale: true, }, skip, take: batchSize, }); if (tracks.length === 0) break; for (const track of tracks) { const moodScores = this.calculateMoodScores(track); const moodsToAssign = Object.entries(moodScores) .filter(([_, score]) => score > 0) .map(([mood, score]) => ({ trackId: track.id, mood, score, })); if (moodsToAssign.length > 0) { // Use upsert for each mood await Promise.all( moodsToAssign.map((data) => prisma.moodBucket.upsert({ where: { trackId_mood: { trackId: data.trackId, mood: data.mood, }, }, create: { trackId: data.trackId, mood: data.mood, score: data.score, }, update: { score: data.score, }, }) ) ); assigned += moodsToAssign.length; } processed++; } skip += batchSize; console.log( `[MoodBucket] Backfill progress: ${processed} tracks processed, ${assigned} mood assignments` ); } console.log( `[MoodBucket] Backfill complete: ${processed} tracks processed, ${assigned} mood assignments` ); return { processed, assigned }; } /** * Clear all mood bucket data for a track * Used when a track is re-analyzed */ async clearTrackMoods(trackId: string): Promise { await prisma.moodBucket.deleteMany({ where: { trackId }, }); } } export const moodBucketService = new MoodBucketService();