65 lines
2.4 KiB
TypeScript
65 lines
2.4 KiB
TypeScript
/**
|
|
* 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}`;
|
|
}
|