diff --git a/scripts/generate-bgm.mjs b/scripts/generate-bgm.mjs index 6d5a9d9..e8ee36d 100644 --- a/scripts/generate-bgm.mjs +++ b/scripts/generate-bgm.mjs @@ -9,99 +9,85 @@ mkdirSync(outputDir, { recursive: true }); const tracks = [ { name: 'title-theme', - root: 45, - tempo: 76, - bars: 16, + root: 50, + tempo: 64, + bars: 12, progression: [ - [0, 7, 12, 16], - [5, 12, 17, 21], - [-3, 4, 9, 12], - [7, 14, 19, 23] + [0, 5, 9], + [2, 7, 11], + [-3, 2, 7], + [0, 5, 12] ], - 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' + melody: [12, 14, 16, 19, 21, 19, 16, 14], + warmth: 1, + motion: 0.45 }, { name: 'story-dark', - root: 43, - tempo: 62, - bars: 12, + root: 45, + tempo: 56, + bars: 10, progression: [ - [0, 7, 10, 15], - [-5, 2, 7, 10], - [-2, 5, 9, 12], - [-7, 0, 5, 10] + [0, 3, 7], + [-5, 0, 5], + [-2, 2, 7], + [-7, 0, 3] ], - melody: [10, 7, 5, 3, 0, 3, 5, 7], - brass: 0.34, - choir: 0.42, - percussion: 0.22, - motion: 0.22, - color: 'dark' + melody: [10, 7, 5, 3, 5, 7], + warmth: 0.72, + motion: 0.18 }, { name: 'oath-theme', - root: 48, - tempo: 68, - bars: 16, + root: 52, + tempo: 60, + bars: 12, progression: [ - [0, 7, 12, 16], - [9, 16, 21, 24], - [5, 12, 17, 21], - [7, 14, 19, 23] + [0, 5, 9], + [4, 9, 12], + [5, 9, 14], + [2, 7, 11] ], - 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' + melody: [12, 16, 19, 21, 24, 21, 19, 16], + warmth: 1, + motion: 0.32 }, { name: 'militia-theme', - root: 41, - tempo: 88, - bars: 16, + root: 48, + tempo: 72, + bars: 12, progression: [ - [0, 7, 12, 15], - [5, 12, 17, 20], - [7, 14, 19, 22], - [-2, 5, 10, 14] + [0, 5, 9], + [5, 9, 12], + [7, 11, 14], + [2, 7, 11] ], - 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' + melody: [12, 14, 16, 19, 16, 14, 12, 14], + warmth: 0.9, + motion: 0.5 }, { name: 'battle-prep', - root: 40, - tempo: 96, - bars: 16, + root: 43, + tempo: 68, + bars: 12, progression: [ - [0, 7, 10, 15], - [3, 10, 15, 19], - [-2, 5, 10, 14], - [-5, 2, 7, 10] + [0, 3, 7], + [2, 7, 10], + [-2, 3, 7], + [-5, 0, 5] ], - 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' + 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.92); + normalize(buffer, 0.48); + applySoftLimiter(buffer); const fileName = join(outputDir, `${track.name}.wav`); writeFileSync(fileName, encodeWav(buffer)); console.log(`Generated ${fileName}`); @@ -116,282 +102,108 @@ function buildTrack(track) { 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 lift = 0.82 + Math.sin((barIndex / track.bars) * Math.PI) * 0.18; 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); + 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); + }); - if (track.brass > 0.3) { - addBrassChord(buffer, start + beat * 1.85, beat * 2.0, upperNotes, track.brass * 0.058 * lift, track.color); - } + 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.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); + if (track.motion > 0.25) { + addGentlePulse(buffer, start, beat, track.root, chord, track.motion * 0.026); } } - addMelody(buffer, track, beat, bar); - addRoomTone(buffer, 0.012); - smoothEdges(buffer, sampleRate * 0.08); - + addSoftMelody(buffer, track, beat, bar); + smoothEdges(buffer, Math.floor(sampleRate * 1.2)); return buffer; } -function addMelody(buffer, track, beat, bar) { - const noteLength = beat * (track.color === 'dark' ? 1.35 : 1.05); +function addSoftMelody(buffer, track, beat, bar) { 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 - }); + 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 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'; +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 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 - }); + const midi = root + chord[pattern[i] % chord.length] + 12; + addPluckedString(buffer, start + i * beat * 0.5, beat * 0.42, midiToFrequency(midi), gain); } } -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') { +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) * 0.78 + - Math.sin(phase * 2.01) * 0.25 + - Math.sin(phase * 3.02) * 0.12 + - Math.sin(phase * 0.997) * 0.18 + Math.sin(phase + slow) * 0.72 + + Math.sin(phase * 2.001) * 0.12 + + Math.sin(phase * 0.5) * 0.08 ); - } + }, 0.78, 1.05); +} - if (profile === 'stringsAir') { - return Math.sin(phase) * 0.64 + Math.sin(phase * 2) * 0.16 + noise(index) * 0.035; - } +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); +} - if (profile === 'lowStrings') { - return Math.sin(phase) * 0.82 + Math.sin(phase * 2) * 0.2 + Math.sin(phase * 3) * 0.08; - } +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); +} - 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 - ); - } +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); +} - 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; - } +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)); - if (profile === 'flute') { - return Math.sin(phase) * 0.78 + Math.sin(phase * 2) * 0.08 + noise(index) * 0.018; - } + for (let i = 0; i < count; i += 1) { + const index = startIndex + i; + if (index >= buffer.length) { + break; + } - if (profile === 'hornLead') { - return Math.sin(phase) * 0.72 + Math.sin(phase * 2) * 0.25 + Math.sin(phase * 3) * 0.11; + 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; } - - 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)); + 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 edge = i / samples; - const fade = Math.sin(edge * Math.PI * 0.5); - buffer[i] *= fade; - buffer[buffer.length - 1 - i] *= fade; + 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; } } @@ -399,14 +211,6 @@ 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) { @@ -423,6 +227,12 @@ function normalize(buffer, target) { } } +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; diff --git a/src/assets/audio/bgm/battle-prep.wav b/src/assets/audio/bgm/battle-prep.wav index 8f48e5f..35f14e0 100644 Binary files a/src/assets/audio/bgm/battle-prep.wav and b/src/assets/audio/bgm/battle-prep.wav differ diff --git a/src/assets/audio/bgm/militia-theme.wav b/src/assets/audio/bgm/militia-theme.wav index 1d036e0..dc8dc67 100644 Binary files a/src/assets/audio/bgm/militia-theme.wav and b/src/assets/audio/bgm/militia-theme.wav differ diff --git a/src/assets/audio/bgm/oath-theme.wav b/src/assets/audio/bgm/oath-theme.wav index 6fa1a14..cb8622d 100644 Binary files a/src/assets/audio/bgm/oath-theme.wav and b/src/assets/audio/bgm/oath-theme.wav differ diff --git a/src/assets/audio/bgm/story-dark.wav b/src/assets/audio/bgm/story-dark.wav index 63c94ad..0475dd8 100644 Binary files a/src/assets/audio/bgm/story-dark.wav and b/src/assets/audio/bgm/story-dark.wav differ diff --git a/src/assets/audio/bgm/title-theme.wav b/src/assets/audio/bgm/title-theme.wav index da85c09..4f65372 100644 Binary files a/src/assets/audio/bgm/title-theme.wav and b/src/assets/audio/bgm/title-theme.wav differ diff --git a/src/game/audio/SoundDirector.ts b/src/game/audio/SoundDirector.ts index 0d87452..da6a0e8 100644 --- a/src/game/audio/SoundDirector.ts +++ b/src/game/audio/SoundDirector.ts @@ -10,7 +10,7 @@ class SoundDirector { private currentMusicKey?: string; private pendingMusicKey?: string; private musicFadeTimer?: number; - private readonly musicVolume = 0.56; + private readonly musicVolume = 0.2; registerMusicTracks(tracks: AudioTrackMap) { this.musicTracks = tracks; @@ -66,12 +66,12 @@ class SoundDirector { } playHover() { - this.playTone(660, 0.035, 0.04, 'sine'); + this.playTone(540, 0.04, 0.018, 'sine'); } playSelect() { - this.playTone(330, 0.08, 0.12, 'triangle'); - window.setTimeout(() => this.playTone(495, 0.1, 0.08, 'triangle'), 65); + this.playTone(300, 0.07, 0.04, 'sine'); + window.setTimeout(() => this.playTone(450, 0.08, 0.028, 'sine'), 70); } playMusic(key?: string) {