const express = require('express'); const router = express.Router(); const axios = require('axios'); const { spawn } = require('child_process'); const db = require('../db'); const camelotToMusicKey = require('../../shared/camelot-to-musickey'); const reverseMap = { // 12B / 12A (E-Dur / C#m / Dbm) 'e': '12B', 'e major': '12B', 'e-dur': '12B', 'e maj': '12B', 'c# minor': '12A', 'c#m': '12A', 'c#-moll': '12A', 'c# min': '12A', 'db minor': '12A', 'dbm': '12A', 'db-moll': '12A', // 1B / 1A (B-Dur / H-Dur / G#m / Abm) 'b': '1B', 'b major': '1B', 'b-dur': '1B', 'b maj': '1B', 'h': '1B', 'h major': '1B', 'h-dur': '1B', 'g# minor': '1A', 'g#m': '1A', 'g#-moll': '1A', 'g# min': '1A', 'ab minor': '1A', 'abm': '1A', 'ab-moll': '1A', // 2B / 2A (F#-Dur / Gb-Dur / D#m / Ebm) 'f#': '2B', 'f# major': '2B', 'f#-dur': '2B', 'f# maj': '2B', 'gb': '2B', 'gb major': '2B', 'gb-dur': '2B', 'd# minor': '2A', 'd#m': '2A', 'd#-moll': '2A', 'd# min': '2A', 'eb minor': '2A', 'ebm': '2A', 'eb-moll': '2A', // 3B / 3A (Db-Dur / C#-Dur / Bbm / A#m) 'db': '3B', 'db major': '3B', 'db-dur': '3B', 'db maj': '3B', 'c#': '3B', 'c# major': '3B', 'c#-dur': '3B', 'bb minor': '3A', 'bbm': '3A', 'bb-moll': '3A', 'bb min': '3A', 'a# minor': '3A', 'a#m': '3A', 'a#-moll': '3A', // 4B / 4A (Ab-Dur / G#-Dur / Fm) 'ab': '4B', 'ab major': '4B', 'ab-dur': '4B', 'ab maj': '4B', 'g#': '4B', 'g# major': '4B', 'g#-dur': '4B', 'f minor': '4A', 'fm': '4A', 'f-moll': '4A', 'f min': '4A', // 5B / 5A (Eb-Dur / D#-Dur / Cm) 'eb': '5B', 'eb major': '5B', 'eb-dur': '5B', 'eb maj': '5B', 'd#': '5B', 'd# major': '5B', 'd#-dur': '5B', 'c minor': '5A', 'cm': '5A', 'c-moll': '5A', 'c min': '5A', // 6B / 6A (Bb-Dur / A#-Dur / Gm) 'bb': '6B', 'bb major': '6B', 'bb-dur': '6B', 'bb maj': '6B', 'a#': '6B', 'a# major': '6B', 'a#-dur': '6B', 'g minor': '6A', 'gm': '6A', 'g-moll': '6A', 'g min': '6A', // 7B / 7A (F-Dur / Dm) 'f': '7B', 'f major': '7B', 'f-dur': '7B', 'f maj': '7B', 'd minor': '7A', 'dm': '7A', 'd-moll': '7A', 'd min': '7A', // 8B / 8A (C-Dur / Am) 'c': '8B', 'c major': '8B', 'c-dur': '8B', 'c maj': '8B', 'a minor': '8A', 'am': '8A', 'a-moll': '8A', 'a min': '8A', // 9B / 9A (G-Dur / Em) 'g': '9B', 'g major': '9B', 'g-dur': '9B', 'g maj': '9B', 'e minor': '9A', 'em': '9A', 'e-moll': '9A', 'e min': '9A', // 10B / 10A (D-Dur / Bm / Hm) 'd': '10B', 'd major': '10B', 'd-dur': '10B', 'd maj': '10B', 'b minor': '10A', 'bm': '10A', 'b-moll': '10A', 'b min': '10A', 'h minor': '10A', 'hm': '10A', 'h-moll': '10A', 'h min': '10A', // 11B / 11A (A-Dur / F#m / Gbm) 'a': '11B', 'a major': '11B', 'a-dur': '11B', 'a maj': '11B', 'f# minor': '11A', 'f#m': '11A', 'f#-moll': '11A', 'f# min': '11A', 'gb minor': '11A', 'gbm': '11A', 'gb-moll': '11A', }; function standardizeKey(keyString) { if (!keyString) return null; const str = keyString.toLowerCase().trim(); const upper = str.toUpperCase(); if (camelotToMusicKey[upper]) return upper; return reverseMap[str] || null; } function estimateAudioFeatures(bpm, camelotKey, tags = []) { let baseEnergy = Math.min(98, Math.max(35, Math.round(30 + ((bpm - 70) / 100) * 60))); let baseDanceability = 70; let baseHappiness = camelotKey && camelotKey.endsWith('B') ? 68 : 45; const tagStr = (tags || []).join(' ').toLowerCase(); if (tagStr.includes('ambient') || tagStr.includes('acoustic') || tagStr.includes('chill')) { baseEnergy = Math.max(25, baseEnergy - 30); baseDanceability = Math.max(30, baseDanceability - 25); } if (tagStr.includes('tech') || tagStr.includes('club') || tagStr.includes('banger') || tagStr.includes('rave')) { baseEnergy = Math.min(98, baseEnergy + 15); baseDanceability = Math.min(95, baseDanceability + 12); } if (tagStr.includes('house') || tagStr.includes('disco') || tagStr.includes('dance')) { baseDanceability = Math.min(92, baseDanceability + 10); } return { energy: Math.min(99, Math.max(15, baseEnergy)), danceability: Math.min(99, Math.max(20, baseDanceability)), happiness: Math.min(99, Math.max(10, baseHappiness)) }; } const MAJOR_PROFILE = [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88]; const MINOR_PROFILE = [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17]; const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; function normalizeTitle(str) { if (!str) return ''; return str .toLowerCase() .replace(/\(.*?\)|\[.*?\]/g, '') // strip (feat. ...), [feat. ...] .replace(/ - .*$/g, '') // strip trailing - Radio Edit, etc. .replace(/[^a-z0-9]/g, ' ') .replace(/\s+/g, ' ') .trim(); } function normalizeArtist(str) { if (!str) return ''; const first = str.split(/,|&|feat\.|with|vs\./i)[0]; return first .toLowerCase() .replace(/[^a-z0-9]/g, ' ') .replace(/\s+/g, ' ') .trim(); } function pearsonCorrelation(x, y) { const n = x.length; let sX = 0, sY = 0, sXY = 0, sX2 = 0, sY2 = 0; for (let i = 0; i < n; i++) { sX += x[i]; sY += y[i]; sXY += x[i] * y[i]; sX2 += x[i] * x[i]; sY2 += y[i] * y[i]; } const den = Math.sqrt((n * sX2 - sX * sX) * (n * sY2 - sY * sY)); return den === 0 ? 0 : (n * sXY - sX * sY) / den; } function detectBpmFromSamples(samples, sampleRate) { const frameSize = 512; const numFrames = Math.floor(samples.length / frameSize); if (numFrames < 20) return null; const energies = new Float32Array(numFrames); for (let i = 0; i < numFrames; i++) { let sum = 0; const off = i * frameSize; for (let j = 0; j < frameSize; j++) { const s = samples[off + j]; sum += s * s; } energies[i] = sum; } const onsets = new Float32Array(numFrames); for (let i = 1; i < numFrames; i++) { const diff = energies[i] - energies[i - 1]; onsets[i] = diff > 0 ? diff : 0; } const fps = sampleRate / frameSize; const minLag = Math.floor((60 * fps) / 175); const maxLag = Math.floor((60 * fps) / 75); let bestLag = minLag; let maxCorr = -Infinity; for (let lag = minLag; lag <= maxLag; lag++) { let corr = 0; for (let i = 0; i < numFrames - lag; i++) { corr += onsets[i] * onsets[i + lag]; } if (corr > maxCorr) { maxCorr = corr; bestLag = lag; } } return Math.round((60 * fps) / bestLag); } function detectKeyFromSamples(samples, sampleRate) { const chroma = new Float64Array(12); for (let midi = 36; midi <= 84; midi++) { const freq = 440 * Math.pow(2, (midi - 69) / 12); const pitchClass = midi % 12; const k = Math.round((samples.length * freq) / sampleRate); const omega = (2 * Math.PI * k) / samples.length; const coeff = 2 * Math.cos(omega); let sPrev = 0, sPrev2 = 0; for (let i = 0; i < samples.length; i += 4) { const s = samples[i] + coeff * sPrev - sPrev2; sPrev2 = sPrev; sPrev = s; } const power = sPrev2 * sPrev2 + sPrev * sPrev - coeff * sPrev * sPrev2; if (power > 0) chroma[pitchClass] += Math.sqrt(power); } const maxChroma = Math.max(...chroma) || 1; const normChroma = Array.from(chroma).map(v => v / maxChroma); let bestScore = -Infinity; let bestKeyStr = 'c major'; for (let root = 0; root < 12; root++) { const shifted = []; for (let i = 0; i < 12; i++) shifted.push(normChroma[(root + i) % 12]); const corrMaj = pearsonCorrelation(shifted, MAJOR_PROFILE); if (corrMaj > bestScore) { bestScore = corrMaj; bestKeyStr = `${NOTE_NAMES[root].toLowerCase()} major`; } const corrMin = pearsonCorrelation(shifted, MINOR_PROFILE); if (corrMin > bestScore) { bestScore = corrMin; bestKeyStr = `${NOTE_NAMES[root].toLowerCase()} minor`; } } const camelot = standardizeKey(bestKeyStr) || '8B'; return { camelotKey: camelot, confidence: Math.round(bestScore * 100) }; } function analyzeAudioPreview(previewUrl) { return new Promise((resolve) => { if (!previewUrl) return resolve(null); const ffmpeg = spawn('ffmpeg', [ '-i', previewUrl, '-f', 's16le', '-ac', '1', '-ar', '11025', '-t', '25', '-' ]); const chunks = []; ffmpeg.stdout.on('data', chunk => chunks.push(chunk)); ffmpeg.on('close', code => { if (code !== 0 || chunks.length === 0) return resolve(null); try { const buf = Buffer.concat(chunks); const numSamples = Math.floor(buf.length / 2); const samples = new Float32Array(numSamples); for (let i = 0; i < numSamples; i++) { samples[i] = buf.readInt16LE(i * 2) / 32768.0; } const bpm = detectBpmFromSamples(samples, 11025); const keyInfo = detectKeyFromSamples(samples, 11025); if (bpm && keyInfo && keyInfo.camelotKey) { return resolve({ bpm, camelotKey: keyInfo.camelotKey, confidence: keyInfo.confidence, source: 'audio_analysis' }); } } catch (e) { console.warn('[Metadata] Audio analysis error:', e.message); } resolve(null); }); ffmpeg.on('error', (err) => { console.warn('[Metadata] ffmpeg spawn error:', err.message); resolve(null); }); }); } async function fetchSongBpm(artist, title) { const normArt = normalizeArtist(artist); const normTitle = normalizeTitle(title); const variants = [ { a: normArt, t: normTitle }, { a: artist, t: title } ]; for (const v of variants) { const aSlug = v.a.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); const tSlug = v.t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); if (!aSlug || !tSlug) continue; try { const r = await axios.get(`https://songbpm.com/@${aSlug}/${tSlug}`, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }, timeout: 3500 }); const kMatch = r.data.match(/([A-G][b#]?)<\/span>\s*key\s*and\s*a\s*(minor|major)<\/span>\s*mode/i); const bpmMatch = r.data.match(/(\d+)\s*\s*BPM<\/span>/i); if (kMatch && bpmMatch) { const fullKey = `${kMatch[1].toLowerCase()} ${kMatch[2].toLowerCase()}`; const camelot = standardizeKey(fullKey); const bpm = parseInt(bpmMatch[1], 10); if (camelot && bpm) { return { bpm, camelotKey: camelot, source: 'songbpm' }; } } } catch (e) { // ignore } } return null; } router.get('/', async (req, res) => { const track = req.query.track; const artist = req.query.artist; if (!track || !artist) { return res.status(400).json({ error: 'Missing track or artist' }); } try { // 1. Check PostgreSQL first (exact + fuzzy match on 26,000+ tracks) const local = await db.findTrack(artist, track); if (local && local.camelot_key && local.bpm) { const est = estimateAudioFeatures(local.bpm, local.camelot_key, local.tags); return res.json({ status: 'success', data: { key: local.camelot_key, bpm: local.bpm, music_key: local.music_key || camelotToMusicKey[local.camelot_key] || '', tags: local.tags || ['dance'], is_corrected: !!local.is_corrected, cover_url: local.cover_url || null, preview_url: local.preview_url || null, deezer_id: local.deezer_id || null, deezer_link: local.deezer_link || (local.deezer_id ? `https://www.deezer.com/track/${local.deezer_id}` : null), duration_sec: local.duration_sec || 210, energy: local.energy || est.energy, danceability: local.danceability || est.danceability, happiness: local.happiness || est.happiness, source: local.source || 'postgresql' } }); } let bpm = null; let camelotKey = null; let tags = []; let coverUrl = null; let durationSec = 210; let previewUrl = null; let deezerLink = null; let deezerId = null; let detectedSource = null; // 2. Query Deezer for cover, duration, preview, and deezer links try { const deezerRes = await axios.get(`https://api.deezer.com/search?q=${encodeURIComponent(artist + ' ' + track)}&limit=1`, { timeout: 3500 }); if (deezerRes.data && deezerRes.data.data && deezerRes.data.data[0]) { const dTrack = deezerRes.data.data[0]; if (dTrack.album && dTrack.album.cover_medium) { coverUrl = dTrack.album.cover_medium; } if (dTrack.duration) { durationSec = dTrack.duration; } if (dTrack.preview) { previewUrl = dTrack.preview; } if (dTrack.link) { deezerLink = dTrack.link; } if (dTrack.id) { deezerId = dTrack.id; if (!deezerLink) deezerLink = `https://www.deezer.com/track/${dTrack.id}`; } } } catch (e) { // ignore } // 3. Try SongBPM lookup (exact calibrated musical data) const songBpmHit = await fetchSongBpm(artist, track); if (songBpmHit) { bpm = songBpmHit.bpm; camelotKey = songBpmHit.camelotKey; detectedSource = songBpmHit.source; } // 4. Query GetSongBPM API with normalized clean parameters if key still missing if (!camelotKey && process.env.GETSONGBPM_API_KEY) { try { const cleanTitle = normalizeTitle(track); const cleanArtist = normalizeArtist(artist); const gsbRes = await axios.get('https://api.getsong.co/search/', { params: { api_key: process.env.GETSONGBPM_API_KEY, type: 'both', lookup: `song:${cleanTitle} artist:${cleanArtist}` }, timeout: 4000 }); const searchResults = gsbRes.data && gsbRes.data.search; if (Array.isArray(searchResults) && searchResults.length > 0) { const song = searchResults[0]; if (song.tempo && !bpm) { bpm = parseFloat(song.tempo); } if (song.key_of) { camelotKey = standardizeKey(song.key_of); if (camelotKey) detectedSource = 'getsongbpm'; } if (song.artist && song.artist.genres && tags.length === 0) { tags = song.artist.genres.slice(0, 4); } } } catch (err) { console.warn('[Metadata] GetSongBPM Error:', err.message); } } // 5. Real-time Audio Preview Analysis via ffmpeg if key still not determined if ((!camelotKey || !bpm) && previewUrl) { try { const audioHit = await analyzeAudioPreview(previewUrl); if (audioHit) { if (!bpm && audioHit.bpm) bpm = audioHit.bpm; if (!camelotKey && audioHit.camelotKey) { camelotKey = audioHit.camelotKey; detectedSource = audioHit.source; } } } catch (err) { console.warn('[Metadata] Audio analysis failed:', err.message); } } // 6. Last.fm API for genre tags if (process.env.LASTFM_API_KEY && tags.length === 0) { try { const lfmRes = await axios.get('https://ws.audioscrobbler.com/2.0/', { params: { method: 'track.getInfo', api_key: process.env.LASTFM_API_KEY, artist: normalizeArtist(artist), track: normalizeTitle(track), format: 'json' }, timeout: 3500 }); if (lfmRes.data && lfmRes.data.track && lfmRes.data.track.toptags && lfmRes.data.track.toptags.tag) { tags = lfmRes.data.track.toptags.tag .slice(0, 4) .map(t => t.name.toLowerCase()); } } catch (err) { console.warn('[Metadata] Last.fm Error:', err.message); } } if (tags.length === 0) tags = ['electronic', 'dance']; const hasRealKey = !!camelotKey; const finalBpm = bpm ? Math.round(bpm) : 120; const finalKey = camelotKey || '8B'; const finalSource = detectedSource || (hasRealKey ? 'online' : 'unverified'); const musicKey = camelotToMusicKey[finalKey] || ''; const audioAttrs = estimateAudioFeatures(finalBpm, finalKey, tags); // 7. Persist only if key was legitimately determined (do NOT pollute DB with blind unverified 8B) if (hasRealKey) { await db.saveTrack({ title: track, artist: artist, camelot_key: finalKey, bpm: finalBpm, music_key: musicKey, cover_url: coverUrl, preview_url: previewUrl, duration_sec: durationSec, energy: audioAttrs.energy, danceability: audioAttrs.danceability, happiness: audioAttrs.happiness, tags: tags, is_corrected: false, source: finalSource }); } res.json({ status: 'success', data: { key: finalKey, bpm: finalBpm, music_key: musicKey, tags: tags, is_corrected: false, cover_url: coverUrl, preview_url: previewUrl, duration_sec: durationSec, deezer_id: deezerId, deezer_link: deezerLink, energy: audioAttrs.energy, danceability: audioAttrs.danceability, happiness: audioAttrs.happiness, source: finalSource } }); } catch (err) { console.error('[Metadata] error:', err.message); res.status(500).json({ error: 'Failed to fetch metadata' }); } }); module.exports = router;