152 lines
5.1 KiB
JavaScript
152 lines
5.1 KiB
JavaScript
// gen-music-placeholders.js
|
|
// Synthesizes short loopable ambient WAV files (one per mood) using only Node stdlib.
|
|
// Output: media/music/<mood>.wav
|
|
// Each file: mono, 16-bit, 22050 Hz, ~8 seconds, low-amplitude, non-silent.
|
|
// License: CC0 (synthesized placeholder — replace with real DRM-free tracks).
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const SAMPLE_RATE = 22050;
|
|
const DURATION_SEC = 8;
|
|
const NUM_SAMPLES = SAMPLE_RATE * DURATION_SEC;
|
|
const BITS_PER_SAMPLE = 16;
|
|
const BYTES_PER_SAMPLE = BITS_PER_SAMPLE / 8;
|
|
const CHANNELS = 1;
|
|
|
|
const MOODS = {
|
|
combat: {
|
|
title: 'Combat — Driving Tension',
|
|
// Fast pulsing low sine + harsh high noise burst
|
|
generate(i) {
|
|
const t = i / SAMPLE_RATE;
|
|
const pulse = Math.sin(2 * Math.PI * 80 * t) * 0.15;
|
|
const drive = Math.sin(2 * Math.PI * 160 * t) * 0.08;
|
|
const noise = (Math.random() * 2 - 1) * 0.04;
|
|
const accent = Math.sin(2 * Math.PI * 40 * t) * Math.sin(2 * Math.PI * 2 * t) * 0.1;
|
|
return pulse + drive + noise + accent;
|
|
},
|
|
},
|
|
investigation: {
|
|
title: 'Investigation — Low Ambient Drone',
|
|
generate(i) {
|
|
const t = i / SAMPLE_RATE;
|
|
const drone1 = Math.sin(2 * Math.PI * 55 * t) * 0.12;
|
|
const drone2 = Math.sin(2 * Math.PI * 82.5 * t) * 0.08;
|
|
const sub = Math.sin(2 * Math.PI * 30 * t) * 0.1;
|
|
const shimmer = Math.sin(2 * Math.PI * 220 * t) * 0.02 * Math.sin(2 * Math.PI * 0.25 * t);
|
|
return drone1 + drone2 + sub + shimmer;
|
|
},
|
|
},
|
|
tension: {
|
|
title: 'Tension — Ominous Unease',
|
|
generate(i) {
|
|
const t = i / SAMPLE_RATE;
|
|
const low = Math.sin(2 * Math.PI * 45 * t) * 0.12;
|
|
const dissonance = Math.sin(2 * Math.PI * 67 * t) * 0.08;
|
|
const trill = Math.sin(2 * Math.PI * 330 * t) * 0.03 * (1 + 0.5 * Math.sin(2 * Math.PI * 3 * t));
|
|
const scrape = (Math.random() * 2 - 1) * 0.02 * Math.abs(Math.sin(2 * Math.PI * 1.5 * t));
|
|
return low + dissonance + trill + scrape;
|
|
},
|
|
},
|
|
victory: {
|
|
title: 'Victory — Warm Resolution',
|
|
generate(i) {
|
|
const t = i / SAMPLE_RATE;
|
|
const root = Math.sin(2 * Math.PI * 130.8 * t) * 0.12;
|
|
const third = Math.sin(2 * Math.PI * 164.8 * t) * 0.09;
|
|
const fifth = Math.sin(2 * Math.PI * 196 * t) * 0.07;
|
|
const octave = Math.sin(2 * Math.PI * 261.6 * t) * 0.05;
|
|
const warmth = Math.sin(2 * Math.PI * 65.4 * t) * 0.08;
|
|
return root + third + fifth + octave + warmth;
|
|
},
|
|
},
|
|
eerie: {
|
|
title: 'Eerie — Dissonant Sparse',
|
|
generate(i) {
|
|
const t = i / SAMPLE_RATE;
|
|
const sparse = Math.sin(2 * Math.PI * 110 * t) * 0.06 * (Math.sin(2 * Math.PI * 0.15 * t) > 0.5 ? 1 : 0.05);
|
|
const dissonant = Math.sin(2 * Math.PI * 117 * t) * 0.06 * (Math.sin(2 * Math.PI * 0.15 * t) > 0.5 ? 1 : 0.05);
|
|
const whisper = (Math.random() * 2 - 1) * 0.015 * (Math.sin(2 * Math.PI * 0.5 * t) > 0 ? 1 : 0);
|
|
const sub = Math.sin(2 * Math.PI * 25 * t) * 0.08;
|
|
return sparse + dissonant + whisper + sub;
|
|
},
|
|
},
|
|
};
|
|
|
|
function writeWav(filePath, samples) {
|
|
const numBytes = samples.length * BYTES_PER_SAMPLE;
|
|
const buf = Buffer.alloc(44 + numBytes);
|
|
|
|
// RIFF header
|
|
buf.write('RIFF', 0);
|
|
buf.writeUInt32LE(36 + numBytes, 4);
|
|
buf.write('WAVE', 8);
|
|
|
|
// fmt chunk
|
|
buf.write('fmt ', 12);
|
|
buf.writeUInt32LE(16, 16);
|
|
buf.writeUInt16LE(1, 20); // PCM
|
|
buf.writeUInt16LE(CHANNELS, 22);
|
|
buf.writeUInt32LE(SAMPLE_RATE, 24);
|
|
buf.writeUInt32LE(SAMPLE_RATE * BYTES_PER_SAMPLE * CHANNELS, 28);
|
|
buf.writeUInt16LE(BYTES_PER_SAMPLE * CHANNELS, 32);
|
|
buf.writeUInt16LE(BITS_PER_SAMPLE, 34);
|
|
|
|
// data chunk
|
|
buf.write('data', 36);
|
|
buf.writeUInt32LE(numBytes, 40);
|
|
|
|
for (let i = 0; i < samples.length; i++) {
|
|
let val = samples[i];
|
|
// Clamp to [-1, 1]
|
|
if (val > 1) val = 1;
|
|
if (val < -1) val = -1;
|
|
// Convert to signed 16-bit
|
|
const int16 = val >= 0 ? Math.floor(val * 32767) : Math.ceil(val * 32768);
|
|
buf.writeInt16LE(int16, 44 + i * BYTES_PER_SAMPLE);
|
|
}
|
|
|
|
fs.writeFileSync(filePath, buf);
|
|
}
|
|
|
|
function generateMood(mood, config) {
|
|
const samples = [];
|
|
for (let i = 0; i < NUM_SAMPLES; i++) {
|
|
samples.push(config.generate(i));
|
|
}
|
|
return samples;
|
|
}
|
|
|
|
// Main
|
|
const outDir = path.join(__dirname, '..', 'media', 'music');
|
|
fs.mkdirSync(outDir, { recursive: true });
|
|
|
|
const manifest = [];
|
|
|
|
for (const [mood, config] of Object.entries(MOODS)) {
|
|
const samples = generateMood(mood, config);
|
|
const filePath = path.join(outDir, `${mood}.wav`);
|
|
writeWav(filePath, samples);
|
|
|
|
// Verify non-silent
|
|
let maxAmp = 0;
|
|
for (const s of samples) {
|
|
if (Math.abs(s) > maxAmp) maxAmp = Math.abs(s);
|
|
}
|
|
|
|
console.log(`Generated: ${filePath} (${samples.length} samples, max amplitude: ${maxAmp.toFixed(4)})`);
|
|
|
|
manifest.push({
|
|
mood,
|
|
file: `${mood}.wav`,
|
|
title: config.title,
|
|
license: 'CC0 (synthesized placeholder)',
|
|
});
|
|
}
|
|
|
|
// Write manifest
|
|
const manifestPath = path.join(outDir, 'manifest.json');
|
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
console.log(`\nManifest written: ${manifestPath}`);
|
|
console.log('Done. All placeholder tracks are CC0 synthesized audio.'); |