262 lines
7.1 KiB
JavaScript
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;
|
|
}
|