Initial release
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
// disco-analyzer.ts — Server-side WAV analysis for disco beat visualization.
|
||||
//
|
||||
// Reads WAV stem files, computes RMS energy per ~16ms window,
|
||||
// normalises to [0–1], and saves as a compact JSON file.
|
||||
//
|
||||
// The browser loads just this JSON (~30-60 KB) instead of three WAV
|
||||
// files (~15 MB total). Energy lookup is a pure array index — zero
|
||||
// sync issues with the main player.
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface DiscoData {
|
||||
version: number; // Schema version
|
||||
fps: number; // Analysis windows per second
|
||||
duration: number; // Total duration in seconds
|
||||
kick: number[]; // Energy per window [0–1], 2 decimal places
|
||||
snare: number[];
|
||||
hihat: number[];
|
||||
}
|
||||
|
||||
// ── WAV Parsing ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Handles 16-bit PCM, 24-bit PCM, and 32-bit IEEE float.
|
||||
// Mono or stereo — stereo is mixed to mono.
|
||||
|
||||
interface WavData {
|
||||
sampleRate: number;
|
||||
channels: number;
|
||||
samples: Float32Array; // Mono mix, normalised to [-1, 1]
|
||||
}
|
||||
|
||||
function parseWav(filePath: string): WavData {
|
||||
const buf = fs.readFileSync(filePath);
|
||||
|
||||
// RIFF header
|
||||
const riff = buf.toString('ascii', 0, 4);
|
||||
const wave = buf.toString('ascii', 8, 12);
|
||||
if (riff !== 'RIFF' || wave !== 'WAVE') {
|
||||
throw new Error(`Not a WAV file: ${filePath}`);
|
||||
}
|
||||
|
||||
// Find fmt and data chunks
|
||||
let fmtOffset = -1;
|
||||
let dataOffset = -1;
|
||||
let dataSize = 0;
|
||||
let pos = 12;
|
||||
|
||||
while (pos < buf.length - 8) {
|
||||
const chunkId = buf.toString('ascii', pos, pos + 4);
|
||||
const chunkSize = buf.readUInt32LE(pos + 4);
|
||||
|
||||
if (chunkId === 'fmt ') {
|
||||
fmtOffset = pos + 8;
|
||||
} else if (chunkId === 'data') {
|
||||
dataOffset = pos + 8;
|
||||
dataSize = chunkSize;
|
||||
}
|
||||
|
||||
pos += 8 + chunkSize;
|
||||
// Chunks must be word-aligned
|
||||
if (chunkSize % 2 !== 0) pos++;
|
||||
}
|
||||
|
||||
if (fmtOffset < 0) throw new Error(`No fmt chunk in: ${filePath}`);
|
||||
if (dataOffset < 0) throw new Error(`No data chunk in: ${filePath}`);
|
||||
|
||||
// Parse fmt chunk
|
||||
const audioFormat = buf.readUInt16LE(fmtOffset); // 1=PCM, 3=IEEE float
|
||||
const channels = buf.readUInt16LE(fmtOffset + 2);
|
||||
const sampleRate = buf.readUInt32LE(fmtOffset + 4);
|
||||
const bitsPerSample = buf.readUInt16LE(fmtOffset + 14);
|
||||
|
||||
// Calculate samples
|
||||
const bytesPerSample = bitsPerSample / 8;
|
||||
const totalFrames = Math.floor(dataSize / (bytesPerSample * channels));
|
||||
const samples = new Float32Array(totalFrames);
|
||||
|
||||
// Read PCM data and mix to mono
|
||||
for (let i = 0; i < totalFrames; i++) {
|
||||
let monoSum = 0;
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
const offset = dataOffset + (i * channels + ch) * bytesPerSample;
|
||||
|
||||
let sample: number;
|
||||
if (audioFormat === 3 && bitsPerSample === 32) {
|
||||
// 32-bit IEEE float
|
||||
sample = buf.readFloatLE(offset);
|
||||
} else if (audioFormat === 1 && bitsPerSample === 16) {
|
||||
// 16-bit signed PCM
|
||||
sample = buf.readInt16LE(offset) / 32768;
|
||||
} else if (audioFormat === 1 && bitsPerSample === 24) {
|
||||
// 24-bit signed PCM (manual 3-byte read)
|
||||
const b0 = buf[offset];
|
||||
const b1 = buf[offset + 1];
|
||||
const b2 = buf[offset + 2];
|
||||
const val = (b2 << 16) | (b1 << 8) | b0;
|
||||
sample = (val >= 0x800000 ? val - 0x1000000 : val) / 8388608;
|
||||
} else if (audioFormat === 1 && bitsPerSample === 32) {
|
||||
// 32-bit signed PCM
|
||||
sample = buf.readInt32LE(offset) / 2147483648;
|
||||
} else {
|
||||
throw new Error(`Unsupported WAV format: ${audioFormat}/${bitsPerSample}bit in ${filePath}`);
|
||||
}
|
||||
|
||||
monoSum += sample;
|
||||
}
|
||||
samples[i] = monoSum / channels;
|
||||
}
|
||||
|
||||
return { sampleRate, channels, samples };
|
||||
}
|
||||
|
||||
// ── Energy Analysis ──────────────────────────────────────────────────────────
|
||||
|
||||
const ANALYSIS_FPS = 60; // ~16.7ms windows
|
||||
|
||||
/**
|
||||
* Compute normalised RMS energy per window for a WAV file.
|
||||
* Returns array of values [0–1], rounded to 2 decimal places.
|
||||
*/
|
||||
function analyzeWav(filePath: string): { energy: number[]; duration: number; sampleRate: number } {
|
||||
const wav = parseWav(filePath);
|
||||
const windowSamples = Math.floor(wav.sampleRate / ANALYSIS_FPS);
|
||||
const totalWindows = Math.ceil(wav.samples.length / windowSamples);
|
||||
const energy = new Float32Array(totalWindows);
|
||||
|
||||
// Compute RMS per window
|
||||
let maxRms = 0;
|
||||
for (let w = 0; w < totalWindows; w++) {
|
||||
const start = w * windowSamples;
|
||||
const end = Math.min(start + windowSamples, wav.samples.length);
|
||||
let sumSq = 0;
|
||||
for (let i = start; i < end; i++) {
|
||||
sumSq += wav.samples[i] * wav.samples[i];
|
||||
}
|
||||
const rms = Math.sqrt(sumSq / (end - start));
|
||||
energy[w] = rms;
|
||||
if (rms > maxRms) maxRms = rms;
|
||||
}
|
||||
|
||||
// Normalise to [0–1]
|
||||
const result: number[] = new Array(totalWindows);
|
||||
if (maxRms > 1e-8) {
|
||||
for (let w = 0; w < totalWindows; w++) {
|
||||
result[w] = Math.round((energy[w] / maxRms) * 100) / 100;
|
||||
}
|
||||
} else {
|
||||
result.fill(0);
|
||||
}
|
||||
|
||||
const duration = wav.samples.length / wav.sampleRate;
|
||||
return { energy: result, duration, sampleRate: wav.sampleRate };
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Analyze drum stem WAV files and save a compact disco data JSON file.
|
||||
*
|
||||
* @param songId - Song ID (used for filenames)
|
||||
* @param audioDir - Directory containing stem WAV files
|
||||
* @param stemUrls - Object with kick/snare/hihat URL paths (e.g., "/audio/abc_kick.wav")
|
||||
* @returns URL path to the saved JSON file, or '' if no stems available
|
||||
*/
|
||||
export function analyzeAndSaveDiscoData(
|
||||
songId: string,
|
||||
audioDir: string,
|
||||
stemUrls: { kick?: string; snare?: string; hihat?: string },
|
||||
): string {
|
||||
const stemCount = [stemUrls.kick, stemUrls.snare, stemUrls.hihat].filter(Boolean).length;
|
||||
if (stemCount === 0) {
|
||||
console.log(`[DiscoAnalyzer] Song ${songId}: no stems to analyze`);
|
||||
return '';
|
||||
}
|
||||
|
||||
console.log(`[DiscoAnalyzer] Song ${songId}: analyzing ${stemCount} stem(s)...`);
|
||||
const t0 = Date.now();
|
||||
|
||||
let duration = 0;
|
||||
|
||||
// Analyze each available stem
|
||||
function analyzeStem(url: string | undefined, label: string): number[] {
|
||||
if (!url) return [];
|
||||
const filename = path.basename(url);
|
||||
const filePath = path.join(audioDir, filename);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(`[DiscoAnalyzer] ${label} stem file not found: ${filePath}`);
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const result = analyzeWav(filePath);
|
||||
if (result.duration > duration) duration = result.duration;
|
||||
console.log(`[DiscoAnalyzer] ${label}: ${result.energy.length} windows, ${result.duration.toFixed(1)}s`);
|
||||
return result.energy;
|
||||
} catch (err: any) {
|
||||
console.error(`[DiscoAnalyzer] ${label}: analysis failed: ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const kick = analyzeStem(stemUrls.kick, 'kick');
|
||||
const snare = analyzeStem(stemUrls.snare, 'snare');
|
||||
const hihat = analyzeStem(stemUrls.hihat, 'hihat');
|
||||
|
||||
// Build disco data
|
||||
const data: DiscoData = {
|
||||
version: 1,
|
||||
fps: ANALYSIS_FPS,
|
||||
duration,
|
||||
kick,
|
||||
snare,
|
||||
hihat,
|
||||
};
|
||||
|
||||
// Save to JSON
|
||||
const filename = `${songId}_disco.json`;
|
||||
const filePath = path.join(audioDir, filename);
|
||||
fs.writeFileSync(filePath, JSON.stringify(data));
|
||||
|
||||
const fileSize = fs.statSync(filePath).size;
|
||||
const elapsed = Date.now() - t0;
|
||||
console.log(`[DiscoAnalyzer] Song ${songId}: saved ${filename} (${(fileSize / 1024).toFixed(1)} KB) in ${elapsed}ms`);
|
||||
|
||||
return `/audio/${filename}`;
|
||||
}
|
||||
Reference in New Issue
Block a user