Add music and voice assets
This commit is contained in:
227
scripts/generate-bgm.mjs
Normal file
227
scripts/generate-bgm.mjs
Normal file
@@ -0,0 +1,227 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const sampleRate = 22050;
|
||||
const duration = 32;
|
||||
const outputDir = 'src/assets/audio/bgm';
|
||||
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const tracks = [
|
||||
{
|
||||
name: 'title-theme',
|
||||
root: 196,
|
||||
tempo: 72,
|
||||
drum: 0.34,
|
||||
melody: [0, 2, 4, 7, 9, 7, 4, 2],
|
||||
bass: [0, 0, -5, -3],
|
||||
color: 'warm'
|
||||
},
|
||||
{
|
||||
name: 'story-dark',
|
||||
root: 146.83,
|
||||
tempo: 58,
|
||||
drum: 0.22,
|
||||
melody: [0, -3, 0, 2, -5, -3, 0, -7],
|
||||
bass: [0, -7, -5, -3],
|
||||
color: 'dark'
|
||||
},
|
||||
{
|
||||
name: 'oath-theme',
|
||||
root: 220,
|
||||
tempo: 66,
|
||||
drum: 0.24,
|
||||
melody: [0, 4, 7, 9, 12, 9, 7, 4],
|
||||
bass: [0, -5, -3, -7],
|
||||
color: 'gold'
|
||||
},
|
||||
{
|
||||
name: 'militia-theme',
|
||||
root: 174.61,
|
||||
tempo: 82,
|
||||
drum: 0.42,
|
||||
melody: [0, 2, 5, 7, 9, 7, 5, 2],
|
||||
bass: [0, -5, 0, -3],
|
||||
color: 'earth'
|
||||
},
|
||||
{
|
||||
name: 'battle-prep',
|
||||
root: 164.81,
|
||||
tempo: 94,
|
||||
drum: 0.5,
|
||||
melody: [0, 3, 5, 7, 10, 7, 5, 3],
|
||||
bass: [0, 0, -2, -5],
|
||||
color: 'steel'
|
||||
}
|
||||
];
|
||||
|
||||
for (const track of tracks) {
|
||||
const buffer = new Float32Array(sampleRate * duration);
|
||||
buildTrack(buffer, track);
|
||||
normalize(buffer, 0.86);
|
||||
writeFileSync(join(outputDir, `${track.name}.wav`), encodeWav(buffer));
|
||||
}
|
||||
|
||||
function buildTrack(buffer, track) {
|
||||
const beat = 60 / track.tempo;
|
||||
const bar = beat * 4;
|
||||
|
||||
for (let t = 0; t < duration; t += bar) {
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const bassFreq = note(track.root, track.bass[i % track.bass.length] - 12);
|
||||
addTone(buffer, t + i * beat, beat * 1.9, bassFreq, 0.075, 'soft');
|
||||
addTone(buffer, t + i * beat, beat * 1.9, bassFreq * 2, 0.025, 'soft');
|
||||
}
|
||||
}
|
||||
|
||||
for (let t = 0; t < duration; t += beat * 2) {
|
||||
const step = Math.floor(t / (beat * 2)) % track.melody.length;
|
||||
const freq = note(track.root, track.melody[step]);
|
||||
addPluck(buffer, t + 0.04, beat * 1.65, freq, 0.12, track.color);
|
||||
addPluck(buffer, t + beat * 0.95, beat * 0.9, freq * 1.5, 0.045, track.color);
|
||||
}
|
||||
|
||||
for (let t = 0; t < duration; t += beat) {
|
||||
if (Math.round(t / beat) % 4 === 0) {
|
||||
addDrum(buffer, t, 0.26, track.drum);
|
||||
} else if (track.drum > 0.3 && Math.round(t / beat) % 2 === 0) {
|
||||
addDrum(buffer, t, 0.16, track.drum * 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
for (let t = 0; t < duration; t += bar * 2) {
|
||||
addNoiseWash(buffer, t, bar * 1.8, track.color === 'dark' ? 0.012 : 0.007);
|
||||
}
|
||||
|
||||
for (let i = 0; i < buffer.length; i += 1) {
|
||||
const seconds = i / sampleRate;
|
||||
const edge = Math.min(1, seconds / 0.6, (duration - seconds) / 0.6);
|
||||
buffer[i] *= Math.max(0, edge);
|
||||
}
|
||||
}
|
||||
|
||||
function note(root, semitone) {
|
||||
return root * 2 ** (semitone / 12);
|
||||
}
|
||||
|
||||
function addTone(buffer, start, length, freq, gain, shape) {
|
||||
const startIndex = Math.floor(start * sampleRate);
|
||||
const count = Math.floor(length * sampleRate);
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const index = startIndex + i;
|
||||
if (index >= buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const t = i / sampleRate;
|
||||
const env = Math.sin(Math.PI * Math.min(1, i / count));
|
||||
const wave = Math.sin(2 * Math.PI * freq * t) + Math.sin(2 * Math.PI * freq * 2 * t) * 0.18;
|
||||
buffer[index] += wave * gain * env * (shape === 'soft' ? 0.78 : 1);
|
||||
}
|
||||
}
|
||||
|
||||
function addPluck(buffer, start, length, freq, gain, color) {
|
||||
const startIndex = Math.floor(start * sampleRate);
|
||||
const count = Math.floor(length * sampleRate);
|
||||
const brightness = color === 'gold' ? 0.36 : color === 'steel' ? 0.42 : 0.28;
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const index = startIndex + i;
|
||||
if (index >= buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const t = i / sampleRate;
|
||||
const env = Math.exp(-t * 4.8) * Math.min(1, t / 0.02);
|
||||
const wave =
|
||||
Math.sin(2 * Math.PI * freq * t) +
|
||||
Math.sin(2 * Math.PI * freq * 2 * t) * brightness +
|
||||
Math.sin(2 * Math.PI * freq * 3 * t) * 0.14;
|
||||
buffer[index] += wave * gain * env;
|
||||
}
|
||||
}
|
||||
|
||||
function addDrum(buffer, start, length, gain) {
|
||||
const startIndex = Math.floor(start * sampleRate);
|
||||
const count = Math.floor(length * sampleRate);
|
||||
let seed = 1337 + startIndex;
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const index = startIndex + i;
|
||||
if (index >= buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const t = i / sampleRate;
|
||||
const env = Math.exp(-t * 15);
|
||||
const freq = 96 - t * 120;
|
||||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
||||
const noise = ((seed / 0xffffffff) * 2 - 1) * 0.18;
|
||||
const wave = Math.sin(2 * Math.PI * Math.max(42, freq) * t) + noise;
|
||||
buffer[index] += wave * gain * env;
|
||||
}
|
||||
}
|
||||
|
||||
function addNoiseWash(buffer, start, length, gain) {
|
||||
const startIndex = Math.floor(start * sampleRate);
|
||||
const count = Math.floor(length * sampleRate);
|
||||
let seed = 4219 + startIndex;
|
||||
let previous = 0;
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const index = startIndex + i;
|
||||
if (index >= buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
const raw = (seed / 0xffffffff) * 2 - 1;
|
||||
previous = previous * 0.98 + raw * 0.02;
|
||||
const env = Math.sin(Math.PI * i / count);
|
||||
buffer[index] += previous * gain * env;
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(buffer, target) {
|
||||
let peak = 0;
|
||||
for (const value of buffer) {
|
||||
peak = Math.max(peak, Math.abs(value));
|
||||
}
|
||||
|
||||
if (peak === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scale = target / peak;
|
||||
for (let i = 0; i < buffer.length; i += 1) {
|
||||
buffer[i] *= scale;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeWav(buffer) {
|
||||
const bytesPerSample = 2;
|
||||
const dataSize = buffer.length * bytesPerSample;
|
||||
const wav = Buffer.alloc(44 + dataSize);
|
||||
|
||||
wav.write('RIFF', 0);
|
||||
wav.writeUInt32LE(36 + dataSize, 4);
|
||||
wav.write('WAVE', 8);
|
||||
wav.write('fmt ', 12);
|
||||
wav.writeUInt32LE(16, 16);
|
||||
wav.writeUInt16LE(1, 20);
|
||||
wav.writeUInt16LE(1, 22);
|
||||
wav.writeUInt32LE(sampleRate, 24);
|
||||
wav.writeUInt32LE(sampleRate * bytesPerSample, 28);
|
||||
wav.writeUInt16LE(bytesPerSample, 32);
|
||||
wav.writeUInt16LE(16, 34);
|
||||
wav.write('data', 36);
|
||||
wav.writeUInt32LE(dataSize, 40);
|
||||
|
||||
for (let i = 0; i < buffer.length; i += 1) {
|
||||
const value = Math.max(-1, Math.min(1, buffer[i]));
|
||||
wav.writeInt16LE(Math.round(value * 32767), 44 + i * bytesPerSample);
|
||||
}
|
||||
|
||||
return wav;
|
||||
}
|
||||
Reference in New Issue
Block a user