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