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; }