import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; const sampleRate = 44100; const channels = 2; const outputDir = 'src/assets/audio/bgm'; mkdirSync(outputDir, { recursive: true }); const tracks = [ { name: 'title-theme', root: 50, tempo: 74, bars: 12, mood: 'open', progression: [ [0, 7, 12], [5, 9, 14], [2, 7, 11], [-3, 4, 9] ], melody: [12, 16, 19, 21, 19, 16, 14, 12], harp: [0, 7, 12, 19, 12, 7, 5, 12] }, { name: 'story-dark', root: 45, tempo: 62, bars: 10, mood: 'quiet', progression: [ [0, 7, 10], [-5, 2, 7], [-3, 4, 9], [-7, 0, 7] ], melody: [10, 7, 5, 3, 5, 7], harp: [0, 7, 10, 14, 10, 7] }, { name: 'oath-theme', root: 52, tempo: 68, bars: 12, mood: 'warm', progression: [ [0, 7, 12], [4, 9, 16], [5, 12, 17], [2, 9, 14] ], melody: [12, 16, 19, 21, 24, 21, 19, 16], harp: [0, 7, 12, 16, 19, 16, 12, 7] }, { name: 'militia-theme', root: 48, tempo: 84, bars: 12, mood: 'march', progression: [ [0, 7, 12], [5, 12, 17], [7, 14, 19], [2, 9, 14] ], melody: [12, 14, 16, 19, 16, 14, 12, 14], harp: [0, 7, 12, 7, 5, 12, 7, 12] }, { name: 'battle-prep', root: 43, tempo: 78, bars: 12, mood: 'tense', progression: [ [0, 7, 10], [2, 9, 14], [-2, 5, 10], [-5, 2, 7] ], melody: [12, 15, 17, 19, 17, 15, 12, 10], harp: [0, 7, 10, 15, 10, 7, 2, 10] } ]; for (const track of tracks) { const buffer = buildTrack(track); removeLowRumble(buffer, 72); applySmallRoom(buffer); normalize(buffer, 0.34); applySoftLimiter(buffer); smoothEdges(buffer, Math.floor(sampleRate * 1.6)); 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 = createBuffer(duration); for (let barIndex = 0; barIndex < track.bars; barIndex += 1) { const start = barIndex * bar; const chord = track.progression[barIndex % track.progression.length]; const lift = 0.86 + Math.sin((barIndex / Math.max(1, track.bars - 1)) * Math.PI) * 0.14; addStringBed(buffer, start, bar * 0.96, track.root, chord, lift, track.mood); addCelloPulse(buffer, start, beat, track.root + chord[0], track.mood); addHarpFigure(buffer, start, beat, track.root, chord, track.harp, track.mood); if (track.mood === 'march' || track.mood === 'tense') { addSoftDrum(buffer, start, beat, track.mood); } } addLeadLine(buffer, track, beat, bar); return buffer; } function createBuffer(duration) { const length = Math.ceil(duration * sampleRate); return { left: new Float32Array(length), right: new Float32Array(length) }; } function addStringBed(buffer, start, duration, root, chord, lift, mood) { const baseGain = mood === 'quiet' ? 0.019 : mood === 'tense' ? 0.021 : 0.026; const notes = [ { midi: root + chord[0] + 12, pan: -0.22, gain: baseGain * 0.72 }, { midi: root + chord[1] + 12, pan: 0.24, gain: baseGain * 0.64 }, { midi: root + chord[2] + 12, pan: -0.04, gain: baseGain * 0.56 }, { midi: root + chord[1] + 24, pan: 0.18, gain: baseGain * 0.28 } ]; notes.forEach((note, index) => { addTone(buffer, { start: start + index * 0.045, duration, frequency: midiToFrequency(note.midi), gain: note.gain * lift, pan: note.pan, attack: mood === 'tense' ? 1.1 : 1.45, release: 1.25, wave: softStringWave }); }); } function addCelloPulse(buffer, start, beat, midi, mood) { const pulses = mood === 'quiet' ? [0] : [0, 2]; const gain = mood === 'tense' ? 0.024 : mood === 'march' ? 0.026 : 0.018; pulses.forEach((beatOffset) => { addTone(buffer, { start: start + beat * beatOffset, duration: beat * 1.35, frequency: midiToFrequency(Math.max(43, midi + 12)), gain, pan: -0.08, attack: 0.08, release: 0.55, wave: mellowCelloWave }); }); } function addHarpFigure(buffer, start, beat, root, chord, pattern, mood) { const gain = mood === 'quiet' ? 0.011 : mood === 'tense' ? 0.014 : 0.016; const stepDuration = beat * 0.46; const stepGap = mood === 'quiet' ? beat : beat * 0.5; const steps = mood === 'quiet' ? 4 : 8; for (let i = 0; i < steps; i += 1) { const offset = pattern[i % pattern.length]; const chordTone = chord[i % chord.length]; addPluck(buffer, { start: start + i * stepGap + beat * 0.08, duration: stepDuration, frequency: midiToFrequency(root + offset + 12 + (i % 2 === 0 ? 0 : chordTone % 5)), gain, pan: i % 2 === 0 ? -0.32 : 0.32 }); } } function addLeadLine(buffer, track, beat, bar) { const noteEvery = track.mood === 'quiet' ? 2 : 1; const gain = track.mood === 'tense' ? 0.018 : track.mood === 'quiet' ? 0.016 : 0.021; for (let i = 0; i < track.bars * noteEvery; i += 1) { const barIndex = Math.floor(i / noteEvery); const offsetInBar = noteEvery === 1 ? beat * 1.42 : beat * 1.85; const start = barIndex * bar + offsetInBar + (i % noteEvery) * beat * 1.34; const midi = track.root + track.melody[i % track.melody.length] + 12; addTone(buffer, { start, duration: beat * 1.05, frequency: midiToFrequency(midi), gain, pan: 0.12, attack: 0.13, release: 0.5, wave: woodwindWave }); } } function addSoftDrum(buffer, start, beat, mood) { const accents = mood === 'tense' ? [0, 2] : [0, 1.5, 2.5]; const gain = mood === 'tense' ? 0.034 : 0.026; accents.forEach((beatOffset, index) => { addTone(buffer, { start: start + beat * beatOffset, duration: beat * 0.5, frequency: index === 0 ? 92 : 122, gain: gain * (index === 0 ? 1 : 0.62), pan: 0, attack: 0.006, release: 0.16, wave: drumWave, pluck: true, decay: 8.5 }); }); } function addTone(buffer, options) { const { start, duration, frequency, gain, pan, attack, release, wave, pluck = false, decay = 5 } = options; const startIndex = Math.max(0, Math.floor(start * sampleRate)); const count = Math.max(1, Math.floor(duration * sampleRate)); const leftGain = Math.cos((pan + 1) * Math.PI * 0.25); const rightGain = Math.sin((pan + 1) * Math.PI * 0.25); for (let i = 0; i < count; i += 1) { const index = startIndex + i; if (index >= buffer.left.length) { break; } const t = i / sampleRate; const env = pluck ? Math.exp(-t * decay) * Math.min(1, t / Math.max(0.001, attack)) : envelope(t, duration, attack, release); const phase = 2 * Math.PI * frequency * t; const sample = wave(phase, t) * gain * env; buffer.left[index] += sample * leftGain; buffer.right[index] += sample * rightGain; } } function addPluck(buffer, options) { addTone(buffer, { ...options, attack: 0.01, release: 0.22, wave: harpWave, pluck: true, decay: 4.6 }); } function softStringWave(phase, t) { const bow = 1 + Math.sin(2 * Math.PI * 0.7 * t) * 0.018; return ( Math.sin(phase * bow) * 0.7 + Math.sin(phase * 2) * 0.13 + Math.sin(phase * 3) * 0.045 ); } function mellowCelloWave(phase) { return Math.sin(phase) * 0.78 + Math.sin(phase * 2) * 0.09 + Math.sin(phase * 0.5) * 0.04; } function woodwindWave(phase, t) { const vibrato = Math.sin(2 * Math.PI * 4.2 * t) * 0.006; return Math.sin(phase + vibrato) * 0.86 + Math.sin(phase * 2) * 0.045; } function harpWave(phase) { return Math.sin(phase) * 0.82 + Math.sin(phase * 2) * 0.12 + Math.sin(phase * 3) * 0.035; } function drumWave(phase, t) { const drop = 1 - Math.min(1, t * 5.2) * 0.42; return Math.sin(phase * drop) * 0.92 + Math.sin(phase * 2.02) * 0.06; } function envelope(t, duration, attack, release) { const attackLevel = Math.min(1, t / Math.max(0.001, attack)); const releaseLevel = Math.min(1, (duration - t) / Math.max(0.001, release)); const attackCurve = Math.sin(attackLevel * Math.PI * 0.5); return Math.max(0, Math.min(attackCurve, releaseLevel)); } function removeLowRumble(buffer, cutoff) { highPass(buffer.left, cutoff); highPass(buffer.right, cutoff); } function highPass(samples, cutoff) { const rc = 1 / (2 * Math.PI * cutoff); const dt = 1 / sampleRate; const alpha = rc / (rc + dt); let previousOutput = 0; let previousInput = samples[0] ?? 0; for (let i = 0; i < samples.length; i += 1) { const input = samples[i]; const output = alpha * (previousOutput + input - previousInput); samples[i] = output; previousOutput = output; previousInput = input; } } function applySmallRoom(buffer) { const originalLeft = Float32Array.from(buffer.left); const originalRight = Float32Array.from(buffer.right); const delays = [ { samples: Math.floor(sampleRate * 0.075), gain: 0.09 }, { samples: Math.floor(sampleRate * 0.132), gain: 0.055 } ]; delays.forEach(({ samples, gain }) => { for (let i = samples; i < buffer.left.length; i += 1) { buffer.left[i] += originalRight[i - samples] * gain; buffer.right[i] += originalLeft[i - samples] * gain; } }); } function smoothEdges(buffer, samples) { const limit = Math.min(samples, buffer.left.length); for (let i = 0; i < limit; i += 1) { const fadeIn = Math.sin((i / limit) * Math.PI * 0.5); const fadeOut = Math.sin((i / limit) * Math.PI * 0.5); buffer.left[i] *= fadeIn; buffer.right[i] *= fadeIn; buffer.left[buffer.left.length - 1 - i] *= fadeOut; buffer.right[buffer.right.length - 1 - i] *= fadeOut; } } function midiToFrequency(midi) { return 440 * 2 ** ((midi - 69) / 12); } function normalize(buffer, target) { let peak = 0; for (let i = 0; i < buffer.left.length; i += 1) { peak = Math.max(peak, Math.abs(buffer.left[i]), Math.abs(buffer.right[i])); } if (peak === 0) { return; } const scale = target / peak; for (let i = 0; i < buffer.left.length; i += 1) { buffer.left[i] *= scale; buffer.right[i] *= scale; } } function applySoftLimiter(buffer) { for (let i = 0; i < buffer.left.length; i += 1) { buffer.left[i] = Math.tanh(buffer.left[i] * 1.08) / Math.tanh(1.08); buffer.right[i] = Math.tanh(buffer.right[i] * 1.08) / Math.tanh(1.08); } } function encodeWav(buffer) { const bytesPerSample = 2; const frameCount = buffer.left.length; const dataSize = frameCount * channels * 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(channels, 22); wav.writeUInt32LE(sampleRate, 24); wav.writeUInt32LE(sampleRate * channels * bytesPerSample, 28); wav.writeUInt16LE(channels * bytesPerSample, 32); wav.writeUInt16LE(16, 34); wav.write('data', 36); wav.writeUInt32LE(dataSize, 40); for (let i = 0; i < frameCount; i += 1) { const left = Math.max(-1, Math.min(1, buffer.left[i])); const right = Math.max(-1, Math.min(1, buffer.right[i])); const offset = 44 + i * channels * bytesPerSample; wav.writeInt16LE(Math.round(left * 32767), offset); wav.writeInt16LE(Math.round(right * 32767), offset + bytesPerSample); } return wav; }