229 lines
7.9 KiB
TypeScript
229 lines
7.9 KiB
TypeScript
// 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}`;
|
||
}
|