Files
heros_web/scripts/generate-bgm.mjs
2026-06-22 00:56:58 +09:00

262 lines
7.1 KiB
JavaScript

import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const sampleRate = 32000;
const outputDir = 'src/assets/audio/bgm';
mkdirSync(outputDir, { recursive: true });
const tracks = [
{
name: 'title-theme',
root: 50,
tempo: 64,
bars: 12,
progression: [
[0, 5, 9],
[2, 7, 11],
[-3, 2, 7],
[0, 5, 12]
],
melody: [12, 14, 16, 19, 21, 19, 16, 14],
warmth: 1,
motion: 0.45
},
{
name: 'story-dark',
root: 45,
tempo: 56,
bars: 10,
progression: [
[0, 3, 7],
[-5, 0, 5],
[-2, 2, 7],
[-7, 0, 3]
],
melody: [10, 7, 5, 3, 5, 7],
warmth: 0.72,
motion: 0.18
},
{
name: 'oath-theme',
root: 52,
tempo: 60,
bars: 12,
progression: [
[0, 5, 9],
[4, 9, 12],
[5, 9, 14],
[2, 7, 11]
],
melody: [12, 16, 19, 21, 24, 21, 19, 16],
warmth: 1,
motion: 0.32
},
{
name: 'militia-theme',
root: 48,
tempo: 72,
bars: 12,
progression: [
[0, 5, 9],
[5, 9, 12],
[7, 11, 14],
[2, 7, 11]
],
melody: [12, 14, 16, 19, 16, 14, 12, 14],
warmth: 0.9,
motion: 0.5
},
{
name: 'battle-prep',
root: 43,
tempo: 68,
bars: 12,
progression: [
[0, 3, 7],
[2, 7, 10],
[-2, 3, 7],
[-5, 0, 5]
],
melody: [12, 15, 17, 19, 17, 15, 12, 10],
warmth: 0.82,
motion: 0.42
}
];
for (const track of tracks) {
const buffer = buildTrack(track);
normalize(buffer, 0.48);
applySoftLimiter(buffer);
const fileName = join(outputDir, `${track.name}.wav`);
writeFileSync(fileName, encodeWav(buffer));
console.log(`Generated ${fileName}`);
}
function buildTrack(track) {
const beat = 60 / track.tempo;
const bar = beat * 4;
const duration = bar * track.bars;
const buffer = new Float32Array(Math.ceil(duration * sampleRate));
for (let barIndex = 0; barIndex < track.bars; barIndex += 1) {
const start = barIndex * bar;
const chord = track.progression[barIndex % track.progression.length];
const lift = 0.82 + Math.sin((barIndex / track.bars) * Math.PI) * 0.18;
const bass = track.root + chord[0] - 12;
chord.forEach((step, index) => {
const midi = track.root + step + 12;
addPad(buffer, start + index * 0.04, bar * 1.08, midiToFrequency(midi), 0.07 * track.warmth * lift);
addPad(buffer, start + 0.06 + index * 0.03, bar * 1.02, midiToFrequency(midi + 12), 0.025 * track.warmth * lift);
});
addLowPad(buffer, start, bar * 1.04, midiToFrequency(bass), 0.055 * lift);
addLowPad(buffer, start + beat * 2, bar * 0.54, midiToFrequency(bass + 7), 0.035 * lift);
if (track.motion > 0.25) {
addGentlePulse(buffer, start, beat, track.root, chord, track.motion * 0.026);
}
}
addSoftMelody(buffer, track, beat, bar);
smoothEdges(buffer, Math.floor(sampleRate * 1.2));
return buffer;
}
function addSoftMelody(buffer, track, beat, bar) {
for (let i = 0; i < track.bars * 2; i += 1) {
const barIndex = Math.floor(i / 2);
const start = barIndex * bar + (i % 2 === 0 ? beat * 0.38 : beat * 2.38);
const midi = track.root + track.melody[i % track.melody.length] + 12;
addWoodwind(buffer, start, beat * 1.12, midiToFrequency(midi), 0.034);
}
}
function addGentlePulse(buffer, start, beat, root, chord, gain) {
const pattern = [0, 1, 2, 1, 0, 2, 1, 2];
for (let i = 0; i < pattern.length; i += 1) {
const midi = root + chord[pattern[i] % chord.length] + 12;
addPluckedString(buffer, start + i * beat * 0.5, beat * 0.42, midiToFrequency(midi), gain);
}
}
function addPad(buffer, start, duration, frequency, gain) {
addInstrument(buffer, start, duration, frequency, gain, (phase, t) => {
const slow = Math.sin(2 * Math.PI * 0.18 * t) * 0.03;
return (
Math.sin(phase + slow) * 0.72 +
Math.sin(phase * 2.001) * 0.12 +
Math.sin(phase * 0.5) * 0.08
);
}, 0.78, 1.05);
}
function addLowPad(buffer, start, duration, frequency, gain) {
addInstrument(buffer, start, duration, frequency, gain, (phase) => {
return Math.sin(phase) * 0.84 + Math.sin(phase * 2) * 0.1;
}, 0.46, 0.9);
}
function addWoodwind(buffer, start, duration, frequency, gain) {
addInstrument(buffer, start, duration, frequency, gain, (phase, t) => {
const vibrato = Math.sin(2 * Math.PI * 4.6 * t) * 0.012;
return Math.sin(phase + vibrato) * 0.82 + Math.sin(phase * 2) * 0.06;
}, 0.12, 0.42);
}
function addPluckedString(buffer, start, duration, frequency, gain) {
addInstrument(buffer, start, duration, frequency, gain, (phase) => {
return Math.sin(phase) * 0.75 + Math.sin(phase * 2) * 0.1;
}, 0.02, 0.18, true);
}
function addInstrument(buffer, start, duration, frequency, gain, wave, attack, release, pluck = false) {
const startIndex = Math.max(0, Math.floor(start * sampleRate));
const count = Math.max(1, Math.floor(duration * sampleRate));
for (let i = 0; i < count; i += 1) {
const index = startIndex + i;
if (index >= buffer.length) {
break;
}
const t = i / sampleRate;
const env = pluck
? Math.exp(-t * 5.8) * Math.min(1, t / attack)
: envelope(t, duration, attack, release);
const phase = 2 * Math.PI * frequency * t;
buffer[index] += wave(phase, t) * gain * env;
}
}
function envelope(t, duration, attack, release) {
const attackLevel = Math.min(1, t / attack);
const releaseLevel = Math.min(1, (duration - t) / release);
const curved = Math.sin(Math.min(1, attackLevel) * Math.PI * 0.5);
return Math.max(0, Math.min(curved, releaseLevel));
}
function smoothEdges(buffer, samples) {
for (let i = 0; i < Math.min(samples, buffer.length); i += 1) {
const fadeIn = Math.sin((i / samples) * Math.PI * 0.5);
const fadeOut = Math.sin((i / samples) * Math.PI * 0.5);
buffer[i] *= fadeIn;
buffer[buffer.length - 1 - i] *= fadeOut;
}
}
function midiToFrequency(midi) {
return 440 * 2 ** ((midi - 69) / 12);
}
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 applySoftLimiter(buffer) {
for (let i = 0; i < buffer.length; i += 1) {
buffer[i] = Math.tanh(buffer[i] * 1.15) / Math.tanh(1.15);
}
}
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;
}