/** * Shared audio-analysis contract. * * Values in track_audio_features are deliberately modest: they are inputs to * recommendation heuristics, not a claim that we can infer every Spotify-like * descriptor from local DSP. Keep the persisted set restricted to fields we * actually compute and validate before it reaches the database. */ export const AUDIO_ANALYSIS_VERSION = 2; export const AUDIO_ANALYSIS_FEATURES = ['bpm', 'key', 'energy', 'danceability'] as const; export type AudioAnalysisFeature = typeof AUDIO_ANALYSIS_FEATURES[number]; export const AUDIO_ANALYSIS_JOB_OPTIONS = { attempts: 3, backoff: { type: 'exponential', delay: 5_000 }, removeOnComplete: { age: 7 * 24 * 60 * 60, count: 10_000 }, removeOnFail: { age: 30 * 24 * 60 * 60, count: 10_000 }, } as const; const ENERGY_FLOOR_DBFS = -48; const ENERGY_CEILING_DBFS = -6; export function clampUnit(value: number): number | null { return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : null; } /** * Convert Essentia's mean-square sample energy to a perceptually useful 0..1 * RMS level. A linear multiplier makes normal mastered music saturate at 1; * dBFS preserves the distinction between quiet and loud recordings. */ export function normaliseMeanSquareEnergy(meanSquare: number): number | null { if (!Number.isFinite(meanSquare) || meanSquare < 0) return null; if (meanSquare === 0) return 0; const rms = Math.sqrt(meanSquare); const dbfs = 20 * Math.log10(rms); return clampUnit((dbfs - ENERGY_FLOOR_DBFS) / (ENERGY_CEILING_DBFS - ENERGY_FLOOR_DBFS)); } export function normaliseDanceability(value: number): number | null { if (!Number.isFinite(value) || value < 0) return null; // Essentia Danceability is normally 0..3. Values outside that range are // clamped rather than allowed to poison downstream scoring. return clampUnit(value / 3); } export function validBpm(value: number | null | undefined): number | null { if (!Number.isFinite(value) || value == null || value < 30 || value > 300) return null; return Math.round(value * 10) / 10; } export function validKey(value: string | null | undefined): string | null { if (!value) return null; const key = value.trim(); // Keep tag conventions such as "8A" intact, but reject malformed or // unexpectedly large values before persisting them. return key.length > 0 && key.length <= 32 ? key : null; } export function audioAnalysisJobId(trackId: string): string { return `audio-${trackId}`; }