Initial release

This commit is contained in:
civ
2026-08-16 18:24:52 +07:00
commit 876886a39a
13244 changed files with 2353959 additions and 0 deletions
+228
View File
@@ -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 [01], 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 [01], 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 [01], 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 [01]
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}`;
}