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: 45, tempo: 76, bars: 16, progression: [ [0, 7, 12, 16], [5, 12, 17, 21], [-3, 4, 9, 12], [7, 14, 19, 23] ], melody: [12, 14, 16, 19, 21, 19, 16, 14, 12, 16, 19, 24, 23, 19, 16, 14], brass: 0.9, choir: 0.3, percussion: 0.55, motion: 0.65, color: 'heroic' }, { name: 'story-dark', root: 43, tempo: 62, bars: 12, progression: [ [0, 7, 10, 15], [-5, 2, 7, 10], [-2, 5, 9, 12], [-7, 0, 5, 10] ], melody: [10, 7, 5, 3, 0, 3, 5, 7], brass: 0.34, choir: 0.42, percussion: 0.22, motion: 0.22, color: 'dark' }, { name: 'oath-theme', root: 48, tempo: 68, bars: 16, progression: [ [0, 7, 12, 16], [9, 16, 21, 24], [5, 12, 17, 21], [7, 14, 19, 23] ], melody: [12, 16, 19, 21, 24, 21, 19, 16, 17, 21, 24, 28, 26, 24, 21, 19], brass: 0.72, choir: 0.55, percussion: 0.34, motion: 0.4, color: 'gold' }, { name: 'militia-theme', root: 41, tempo: 88, bars: 16, progression: [ [0, 7, 12, 15], [5, 12, 17, 20], [7, 14, 19, 22], [-2, 5, 10, 14] ], melody: [12, 14, 15, 19, 22, 19, 15, 14, 12, 15, 19, 22, 24, 22, 19, 15], brass: 0.68, choir: 0.18, percussion: 0.78, motion: 0.82, color: 'march' }, { name: 'battle-prep', root: 40, tempo: 96, bars: 16, progression: [ [0, 7, 10, 15], [3, 10, 15, 19], [-2, 5, 10, 14], [-5, 2, 7, 10] ], melody: [12, 15, 17, 19, 22, 19, 17, 15, 12, 10, 12, 15, 17, 19, 22, 24], brass: 0.86, choir: 0.2, percussion: 0.92, motion: 0.9, color: 'steel' } ]; for (const track of tracks) { const buffer = buildTrack(track); normalize(buffer, 0.92); 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.78 + (barIndex / Math.max(1, track.bars - 1)) * 0.34; const chordNotes = chord.map((step) => track.root + step + 12); const upperNotes = chord.map((step) => track.root + step + 24); const bass = track.root + chord[0] - 12; addStringSection(buffer, start, bar * 1.04, chordNotes, 0.085 * lift, track.color); addChoirSection(buffer, start + beat * 0.45, bar * 0.96, upperNotes, track.choir * 0.04, track.color); addLowStrings(buffer, start, bar * 1.02, bass, 0.085 * lift); if (track.brass > 0.3) { addBrassChord(buffer, start + beat * 1.85, beat * 2.0, upperNotes, track.brass * 0.058 * lift, track.color); } if (track.motion > 0.35) { addOstinato(buffer, start, beat, track.root, chord, track.motion * 0.052, track.color); } if (track.percussion > 0.18) { addTimpani(buffer, start, beat * 0.48, midiToFrequency(bass), track.percussion * 0.11); if (barIndex % 2 === 1 || track.percussion > 0.7) { addTimpani(buffer, start + beat * 2, beat * 0.38, midiToFrequency(bass + 7), track.percussion * 0.07); } if (track.percussion > 0.65) { addWarDrum(buffer, start + beat, beat * 0.22, track.percussion * 0.08); addWarDrum(buffer, start + beat * 3, beat * 0.22, track.percussion * 0.07); } } if (barIndex % 4 === 3) { addCymbalSwell(buffer, start + bar - beat * 1.35, beat * 1.2, 0.032 + track.percussion * 0.018); } } addMelody(buffer, track, beat, bar); addRoomTone(buffer, 0.012); smoothEdges(buffer, sampleRate * 0.08); return buffer; } function addMelody(buffer, track, beat, bar) { const noteLength = beat * (track.color === 'dark' ? 1.35 : 1.05); for (let i = 0; i < track.bars * 2; i += 1) { const barIndex = Math.floor(i / 2); const local = i % 2 === 0 ? beat * 0.18 : beat * 2.18; const start = barIndex * bar + local; const step = track.melody[i % track.melody.length]; const midi = track.root + step + 12; const gain = track.color === 'dark' ? 0.045 : 0.062; const profile = track.color === 'gold' ? 'flute' : track.color === 'steel' ? 'hornLead' : 'oboe'; addInstrument(buffer, start, noteLength, midiToFrequency(midi), gain, profile, { attack: 0.05, release: 0.28, vibrato: 0.007 }); } } function addStringSection(buffer, start, duration, midiNotes, gain, color) { midiNotes.forEach((midi, index) => { addInstrument(buffer, start + index * 0.018, duration, midiToFrequency(midi), gain, 'strings', { attack: 0.5, release: 0.72, vibrato: color === 'dark' ? 0.004 : 0.006 }); addInstrument(buffer, start + 0.04 + index * 0.014, duration * 0.96, midiToFrequency(midi + 12), gain * 0.38, 'stringsAir', { attack: 0.62, release: 0.8, vibrato: 0.008 }); }); } function addLowStrings(buffer, start, duration, midi, gain) { addInstrument(buffer, start, duration, midiToFrequency(midi), gain, 'lowStrings', { attack: 0.18, release: 0.55, vibrato: 0.003 }); addInstrument(buffer, start + 0.09, duration * 0.92, midiToFrequency(midi + 12), gain * 0.42, 'lowStrings', { attack: 0.2, release: 0.5, vibrato: 0.004 }); } function addChoirSection(buffer, start, duration, midiNotes, gain, color) { if (gain <= 0) { return; } midiNotes.forEach((midi, index) => { addInstrument(buffer, start + index * 0.03, duration, midiToFrequency(midi), gain, color === 'dark' ? 'darkChoir' : 'choir', { attack: 0.75, release: 0.9, vibrato: 0.002 }); }); } function addBrassChord(buffer, start, duration, midiNotes, gain, color) { midiNotes.slice(0, 3).forEach((midi, index) => { addInstrument(buffer, start + index * 0.012, duration, midiToFrequency(midi), gain, color === 'steel' ? 'battleBrass' : 'brass', { attack: 0.2, release: 0.45, vibrato: 0.004 }); }); } function addOstinato(buffer, barStart, beat, root, chord, gain, color) { const pattern = [0, 2, 1, 3, 2, 1, 0, 2]; const profile = color === 'march' || color === 'steel' ? 'spiccato' : 'pizzicato'; for (let i = 0; i < pattern.length; i += 1) { const step = chord[pattern[i] % chord.length]; const midi = root + step + (i % 2 === 0 ? 12 : 24); addInstrument(buffer, barStart + i * beat * 0.5, beat * 0.42, midiToFrequency(midi), gain, profile, { attack: 0.012, release: 0.12, vibrato: 0 }); } } function addTimpani(buffer, start, duration, baseFrequency, gain) { 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 = Math.exp(-t * 8.5) * Math.min(1, t / 0.018); const frequency = baseFrequency * (1.18 - Math.min(0.28, t * 1.8)); const drum = Math.sin(2 * Math.PI * frequency * t) + Math.sin(2 * Math.PI * frequency * 0.5 * t) * 0.45; buffer[index] += drum * gain * env; } } function addWarDrum(buffer, start, duration, gain) { 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 = Math.exp(-t * 18) * Math.min(1, t / 0.01); const tone = Math.sin(2 * Math.PI * (84 - t * 40) * t); const grit = noise(index) * 0.35; buffer[index] += (tone + grit) * gain * env; } } function addCymbalSwell(buffer, start, duration, gain) { const startIndex = Math.max(0, Math.floor(start * sampleRate)); const count = Math.max(1, Math.floor(duration * sampleRate)); let filtered = 0; for (let i = 0; i < count; i += 1) { const index = startIndex + i; if (index >= buffer.length) { break; } const raw = noise(index * 3 + 17); filtered = filtered * 0.62 + raw * 0.38; const bright = raw - filtered; const t = i / count; const env = Math.sin(Math.PI * t) ** 0.45; buffer[index] += bright * gain * env; } } function addRoomTone(buffer, gain) { let low = 0; for (let i = 0; i < buffer.length; i += 1) { low = low * 0.996 + noise(i + 991) * 0.004; buffer[i] += low * gain; } } function addInstrument(buffer, start, duration, frequency, gain, profile, options) { const startIndex = Math.max(0, Math.floor(start * sampleRate)); const count = Math.max(1, Math.floor(duration * sampleRate)); const attack = options.attack ?? 0.04; const release = options.release ?? 0.2; const vibratoDepth = options.vibrato ?? 0.004; for (let i = 0; i < count; i += 1) { const index = startIndex + i; if (index >= buffer.length) { break; } const t = i / sampleRate; const env = envelope(t, duration, attack, release); const vibrato = 1 + Math.sin(2 * Math.PI * 5.1 * t) * vibratoDepth; const wave = instrumentWave(profile, frequency * vibrato, t, index); buffer[index] += wave * gain * env; } } function instrumentWave(profile, frequency, t, index) { const phase = 2 * Math.PI * frequency * t; if (profile === 'strings') { return ( Math.sin(phase) * 0.78 + Math.sin(phase * 2.01) * 0.25 + Math.sin(phase * 3.02) * 0.12 + Math.sin(phase * 0.997) * 0.18 ); } if (profile === 'stringsAir') { return Math.sin(phase) * 0.64 + Math.sin(phase * 2) * 0.16 + noise(index) * 0.035; } if (profile === 'lowStrings') { return Math.sin(phase) * 0.82 + Math.sin(phase * 2) * 0.2 + Math.sin(phase * 3) * 0.08; } if (profile === 'brass' || profile === 'battleBrass') { const bite = profile === 'battleBrass' ? 0.24 : 0.15; return Math.tanh( Math.sin(phase) * 1.1 + Math.sin(phase * 2) * 0.42 + Math.sin(phase * 3) * bite + Math.sin(phase * 4) * 0.08 ); } if (profile === 'choir' || profile === 'darkChoir') { const hollow = profile === 'darkChoir' ? 0.32 : 0.22; return Math.sin(phase) * 0.62 + Math.sin(phase * 1.5) * hollow + Math.sin(phase * 2) * 0.12; } if (profile === 'flute') { return Math.sin(phase) * 0.78 + Math.sin(phase * 2) * 0.08 + noise(index) * 0.018; } if (profile === 'hornLead') { return Math.sin(phase) * 0.72 + Math.sin(phase * 2) * 0.25 + Math.sin(phase * 3) * 0.11; } if (profile === 'oboe') { return Math.sin(phase) * 0.66 + Math.sin(phase * 2) * 0.28 + Math.sin(phase * 3) * 0.1; } if (profile === 'spiccato') { return Math.sin(phase) * 0.72 + Math.sin(phase * 2) * 0.2 + noise(index) * 0.025; } return Math.sin(phase) * 0.7 + Math.sin(phase * 2) * 0.16; } function envelope(t, duration, attack, release) { const attackLevel = attack <= 0 ? 1 : Math.min(1, t / attack); const releaseLevel = release <= 0 ? 1 : Math.min(1, (duration - t) / release); return Math.max(0, Math.min(1, attackLevel, releaseLevel)); } function smoothEdges(buffer, samples) { for (let i = 0; i < Math.min(samples, buffer.length); i += 1) { const edge = i / samples; const fade = Math.sin(edge * Math.PI * 0.5); buffer[i] *= fade; buffer[buffer.length - 1 - i] *= fade; } } function midiToFrequency(midi) { return 440 * 2 ** ((midi - 69) / 12); } function noise(index) { let value = (index * 1664525 + 1013904223) >>> 0; value ^= value << 13; value ^= value >>> 17; value ^= value << 5; return (value >>> 0) / 0xffffffff * 2 - 1; } 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; }