Files
muzick/workers/src/audio-features.service.ts
T

234 lines
9.1 KiB
TypeScript

/**
* AudioFeaturesService — two-pass audio feature extraction.
*
* Pass 1 (fast): read BPM and key from embedded file tags via music-metadata.
* Many files tagged by beets / MusicBrainz Picard / Mixed In Key already
* carry these, so we get the answer for free.
*
* Pass 2 (compute): run essentia.js (WebAssembly) on the decoded PCM signal
* for anything still missing. Audio decoding is done by a ffmpeg subprocess
* so every format ffmpeg understands (MP3, FLAC, AAC, OGG, …) is supported.
* Computed features: BPM, key, energy, danceability, dynamic complexity.
*
* The two passes are combined via COALESCE so that embedded-tag values are
* never overwritten once they exist.
*/
import { spawn } from 'child_process';
import mm from 'music-metadata';
import { Client as PgClient } from 'pg';
// Lazy WASM singleton — heavy to load (~2.4 MB), so we initialise once and
// reuse across all enrichment jobs within the same worker process.
let essentiaReady: Promise<{ essentia: any }> | null = null;
async function getEssentia() {
if (!essentiaReady) {
essentiaReady = (async () => {
const mod = await import('essentia.js');
// essentia.js v0.1.3: default export contains { Essentia, EssentiaWASM, ... }
// EssentiaWASM is already the instantiated WASM module (not a function)
const EssentiaWASM = mod.default?.EssentiaWASM ?? mod.EssentiaWASM;
const Essentia = mod.default?.Essentia ?? mod.Essentia;
if (!EssentiaWASM || !Essentia) {
throw new Error('essentia.js exports missing EssentiaWASM or Essentia');
}
const essentia = new Essentia(EssentiaWASM);
return { essentia };
})();
}
return essentiaReady;
}
/** Decode any audio file to a mono Float32Array at 44100 Hz via ffmpeg. */
function decodeAudioToFloat32(filePath: string): Promise<Float32Array> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
const ff = spawn('ffmpeg', [
'-i', filePath,
'-vn',
'-acodec', 'pcm_f32le',
'-ar', '44100',
'-ac', '1',
'-f', 'f32le',
'pipe:1',
], { stdio: ['ignore', 'pipe', 'ignore'] });
ff.stdout.on('data', (chunk: Buffer) => chunks.push(chunk));
ff.stdout.on('end', () => {
const buf = Buffer.concat(chunks);
resolve(new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4));
});
ff.on('error', reject);
ff.on('close', (code) => {
if (code !== 0 && chunks.length === 0) {
reject(new Error(`ffmpeg exited with code ${code} for ${filePath}`));
}
});
});
}
export interface AudioFeatures {
bpm: number | null;
key: string | null;
energy: number | null;
danceability: number | null;
dynamicComplexity: number | null;
}
export class AudioFeaturesService {
constructor(private pgClient: PgClient) {}
async ensureSchema(): Promise<void> {
await this.pgClient.query(
`CREATE TABLE IF NOT EXISTS track_audio_features (
track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
bpm REAL,
key TEXT,
energy REAL,
danceability REAL,
valence REAL,
acousticness REAL,
instrumentalness REAL,
liveness REAL,
valence_score REAL,
tempo REAL
)`
);
}
// ── Pass 1: embedded tags ──────────────────────────────────────────────────
private async readEmbeddedTags(filePath: string): Promise<{ bpm: number | null; key: string | null; replayGainDb: number | null }> {
try {
const { common } = await mm.parseFile(filePath, { duration: false });
const bpm = common.bpm && Number.isFinite(common.bpm) && common.bpm > 0 ? common.bpm : null;
const key = common.key?.trim() || null;
const rgRaw = (common as any).replaygain_track_gain;
let replayGainDb: number | null = null;
if (rgRaw != null) {
const val = typeof rgRaw === 'object' ? rgRaw.dB : Number(String(rgRaw).replace(/[^\d.-]/g, ''));
if (Number.isFinite(val)) replayGainDb = val;
}
return { bpm, key, replayGainDb };
} catch {
return { bpm: null, key: null, replayGainDb: null };
}
}
// ── Pass 2: essentia.js DSP ────────────────────────────────────────────────
private async analyseWithEssentia(filePath: string, needBpm: boolean, needKey: boolean): Promise<AudioFeatures> {
const result: AudioFeatures = { bpm: null, key: null, energy: null, danceability: null, dynamicComplexity: null };
const { essentia } = await getEssentia();
const signal = await decodeAudioToFloat32(filePath);
if (signal.length === 0) return result;
const vec = essentia.arrayToVector(signal);
if (needBpm) {
try {
const rhythm = essentia.RhythmExtractor2013(vec);
if (rhythm.bpm > 0) result.bpm = Math.round(rhythm.bpm * 10) / 10;
} catch (err) {
console.warn('[AudioFeatures] RhythmExtractor2013 failed:', (err as Error).message);
}
}
if (needKey) {
try {
const keyResult = essentia.KeyExtractor(vec);
if (keyResult.key) result.key = `${keyResult.key} ${keyResult.scale}`;
} catch (err) {
console.warn('[AudioFeatures] KeyExtractor failed:', (err as Error).message);
}
}
// Energy and danceability are only available from signal analysis — always compute.
try {
const energy = essentia.Energy(vec);
// Essentia Energy returns the sum of squared samples, normalised to [0,1]
// by dividing by signal length. Map to a sensible 0-1 display value.
const rawEnergy = energy.energy / signal.length;
result.energy = Math.min(1, rawEnergy * 1000); // typical values << 0.001
} catch (err) {
console.warn('[AudioFeatures] Energy failed:', (err as Error).message);
}
try {
const dance = essentia.Danceability(vec);
// Danceability output range is 0-3; normalise to 0-1.
result.danceability = Math.min(1, dance.danceability / 3);
} catch (err) {
console.warn('[AudioFeatures] Danceability failed:', (err as Error).message);
}
try {
const dynComp = essentia.DynamicComplexity(vec);
// DynamicComplexity is a measure of loudness variation (0 = flat/compressed,
// higher = dynamic). Scale to 0-1 (typical max ~10 dB).
result.dynamicComplexity = Math.min(1, dynComp.dynamicComplexity / 10);
} catch (err) {
console.warn('[AudioFeatures] DynamicComplexity failed:', (err as Error).message);
}
vec.delete(); // free WASM memory
return result;
}
// ── Public entry point ─────────────────────────────────────────────────────
async extractAndPersist(trackId: string): Promise<void> {
const pathRes = await this.pgClient.query<{ path: string }>(
'SELECT path FROM tracks WHERE id = $1',
[trackId]
);
const row = pathRes.rows[0];
if (!row) {
console.warn(`[AudioFeatures] track not found: ${trackId}`);
return;
}
const tags = await this.readEmbeddedTags(row.path);
// Rough energy proxy from ReplayGain when available (free, no DSP).
let tagEnergy: number | null = null;
if (tags.replayGainDb !== null) {
tagEnergy = Math.min(1, Math.max(0, (tags.replayGainDb + 18) / 21));
}
// Only spin up Essentia when embedded tags are missing something useful.
const needBpm = tags.bpm === null;
const needKey = tags.key === null;
// Always run Essentia for energy/danceability since no tag source covers those.
const dsq = await this.analyseWithEssentia(row.path, needBpm, needKey);
const bpm = tags.bpm ?? dsq.bpm;
const key = tags.key ?? dsq.key;
const energy = tagEnergy ?? dsq.energy;
const danceability = dsq.danceability;
const tempo = bpm;
await this.pgClient.query(
`INSERT INTO track_audio_features (track_id, bpm, key, energy, danceability, tempo)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (track_id) DO UPDATE SET
bpm = COALESCE(EXCLUDED.bpm, track_audio_features.bpm),
key = COALESCE(EXCLUDED.key, track_audio_features.key),
energy = COALESCE(EXCLUDED.energy, track_audio_features.energy),
danceability = COALESCE(EXCLUDED.danceability, track_audio_features.danceability),
tempo = COALESCE(EXCLUDED.tempo, track_audio_features.tempo)`,
[trackId, bpm, key, energy, danceability, tempo]
);
const parts: string[] = [];
if (bpm) parts.push(`bpm:${bpm.toFixed(1)}${tags.bpm ? '(tag)' : '(dsp)'}`);
if (key) parts.push(`key:${key}${tags.key ? '(tag)' : '(dsp)'}`);
if (energy !== null) parts.push(`energy:${energy.toFixed(2)}`);
if (danceability !== null) parts.push(`dance:${danceability.toFixed(2)}`);
console.log(
`[AudioFeatures] ${trackId}: ${parts.length ? parts.join(', ') : 'no features extracted'}`
);
}
}