Files
hot-step-cpp-ROCm/server/src/services/disco-analyzer.ts
T
2026-08-16 18:24:52 +07:00

229 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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}`;
}