diff --git a/docs/roadmap.md b/docs/roadmap.md index 3d1c67e..24dcff4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -15,7 +15,7 @@ Build a small complete tactical RPG loop: - Vite, TypeScript, and Phaser project foundation - Cinematic title scene with an original Taoyuan Oath background, subtle motion, menu UI, and first-input audio - Cinematic prologue pages with high-resolution story backgrounds, character portraits, non-black scene transitions, and scenario data -- File-based original orchestral BGM loops for title, prologue, oath, militia, and battle-prep scenes +- File-based original soft stereo orchestral BGM loops for title, prologue, oath, militia, and battle-prep scenes - First battle scene with a high-resolution battlefield background, large 12x12 tactical grid, high-detail pixel-style unit sprites, movement range preview, click-to-move unit movement, cursor-adjacent post-move command popup, right-click move cancel, and grayscale acted-unit feedback - Flow verification script from title to first battle diff --git a/scripts/generate-bgm.mjs b/scripts/generate-bgm.mjs index e8ee36d..b8f0d13 100644 --- a/scripts/generate-bgm.mjs +++ b/scripts/generate-bgm.mjs @@ -1,7 +1,8 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -const sampleRate = 32000; +const sampleRate = 44100; +const channels = 2; const outputDir = 'src/assets/audio/bgm'; mkdirSync(outputDir, { recursive: true }); @@ -10,84 +11,88 @@ const tracks = [ { name: 'title-theme', root: 50, - tempo: 64, + tempo: 74, bars: 12, + mood: 'open', progression: [ - [0, 5, 9], + [0, 7, 12], + [5, 9, 14], [2, 7, 11], - [-3, 2, 7], - [0, 5, 12] + [-3, 4, 9] ], - melody: [12, 14, 16, 19, 21, 19, 16, 14], - warmth: 1, - motion: 0.45 + melody: [12, 16, 19, 21, 19, 16, 14, 12], + harp: [0, 7, 12, 19, 12, 7, 5, 12] }, { name: 'story-dark', root: 45, - tempo: 56, + tempo: 62, bars: 10, + mood: 'quiet', progression: [ - [0, 3, 7], - [-5, 0, 5], - [-2, 2, 7], - [-7, 0, 3] + [0, 7, 10], + [-5, 2, 7], + [-3, 4, 9], + [-7, 0, 7] ], melody: [10, 7, 5, 3, 5, 7], - warmth: 0.72, - motion: 0.18 + harp: [0, 7, 10, 14, 10, 7] }, { name: 'oath-theme', root: 52, - tempo: 60, + tempo: 68, bars: 12, + mood: 'warm', progression: [ - [0, 5, 9], - [4, 9, 12], - [5, 9, 14], - [2, 7, 11] + [0, 7, 12], + [4, 9, 16], + [5, 12, 17], + [2, 9, 14] ], melody: [12, 16, 19, 21, 24, 21, 19, 16], - warmth: 1, - motion: 0.32 + harp: [0, 7, 12, 16, 19, 16, 12, 7] }, { name: 'militia-theme', root: 48, - tempo: 72, + tempo: 84, bars: 12, + mood: 'march', progression: [ - [0, 5, 9], - [5, 9, 12], - [7, 11, 14], - [2, 7, 11] + [0, 7, 12], + [5, 12, 17], + [7, 14, 19], + [2, 9, 14] ], melody: [12, 14, 16, 19, 16, 14, 12, 14], - warmth: 0.9, - motion: 0.5 + harp: [0, 7, 12, 7, 5, 12, 7, 12] }, { name: 'battle-prep', root: 43, - tempo: 68, + tempo: 78, bars: 12, + mood: 'tense', progression: [ - [0, 3, 7], - [2, 7, 10], - [-2, 3, 7], - [-5, 0, 5] + [0, 7, 10], + [2, 9, 14], + [-2, 5, 10], + [-5, 2, 7] ], melody: [12, 15, 17, 19, 17, 15, 12, 10], - warmth: 0.82, - motion: 0.42 + harp: [0, 7, 10, 15, 10, 7, 2, 10] } ]; for (const track of tracks) { const buffer = buildTrack(track); - normalize(buffer, 0.48); + 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}`); @@ -97,113 +102,264 @@ 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)); + 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.82 + Math.sin((barIndex / track.bars) * Math.PI) * 0.18; - const bass = track.root + chord[0] - 12; + const lift = 0.86 + Math.sin((barIndex / Math.max(1, track.bars - 1)) * Math.PI) * 0.14; - 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); - }); + 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); - 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); + if (track.mood === 'march' || track.mood === 'tense') { + addSoftDrum(buffer, start, beat, track.mood); } } - addSoftMelody(buffer, track, beat, bar); - smoothEdges(buffer, Math.floor(sampleRate * 1.2)); + addLeadLine(buffer, track, beat, bar); 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); +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; - addWoodwind(buffer, start, beat * 1.12, midiToFrequency(midi), 0.034); + + addTone(buffer, { + start, + duration: beat * 1.05, + frequency: midiToFrequency(midi), + gain, + pan: 0.12, + attack: 0.13, + release: 0.5, + wave: woodwindWave + }); } } -function addGentlePulse(buffer, start, beat, root, chord, gain) { - const pattern = [0, 1, 2, 1, 0, 2, 1, 2]; +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; - 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); - } + 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 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) { +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.length) { + if (index >= buffer.left.length) { break; } const t = i / sampleRate; const env = pluck - ? Math.exp(-t * 5.8) * Math.min(1, t / attack) + ? 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; - buffer[index] += wave(phase, t) * gain * env; + 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 / 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)); + 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) { - 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; + 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; } } @@ -213,8 +369,8 @@ function midiToFrequency(midi) { function normalize(buffer, target) { let peak = 0; - for (const value of buffer) { - peak = Math.max(peak, Math.abs(value)); + 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) { @@ -222,20 +378,23 @@ function normalize(buffer, target) { } const scale = target / peak; - for (let i = 0; i < buffer.length; i += 1) { - buffer[i] *= scale; + 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.length; i += 1) { - buffer[i] = Math.tanh(buffer[i] * 1.15) / Math.tanh(1.15); + 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 dataSize = buffer.length * bytesPerSample; + const frameCount = buffer.left.length; + const dataSize = frameCount * channels * bytesPerSample; const wav = Buffer.alloc(44 + dataSize); wav.write('RIFF', 0); @@ -244,17 +403,20 @@ function encodeWav(buffer) { wav.write('fmt ', 12); wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); - wav.writeUInt16LE(1, 22); + wav.writeUInt16LE(channels, 22); wav.writeUInt32LE(sampleRate, 24); - wav.writeUInt32LE(sampleRate * bytesPerSample, 28); - wav.writeUInt16LE(bytesPerSample, 32); + 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 < buffer.length; i += 1) { - const value = Math.max(-1, Math.min(1, buffer[i])); - wav.writeInt16LE(Math.round(value * 32767), 44 + i * bytesPerSample); + 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; diff --git a/src/assets/audio/bgm/battle-prep.wav b/src/assets/audio/bgm/battle-prep.wav index 35f14e0..7779a1c 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 dc8dc67..9203dd2 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 cb8622d..d46d50e 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 0475dd8..03f6fa3 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 4f65372..1944de6 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 da6a0e8..e93c2d0 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.2; + private readonly musicVolume = 0.14; registerMusicTracks(tracks: AudioTrackMap) { this.musicTracks = tracks; @@ -29,7 +29,7 @@ class SoundDirector { if (AudioContextCtor) { this.context = new AudioContextCtor(); this.master = this.context.createGain(); - this.master.gain.value = this.muted ? 0 : 0.34; + this.master.gain.value = this.muted ? 0 : 0.3; this.master.connect(this.context.destination); } @@ -58,7 +58,7 @@ class SoundDirector { } this.master.gain.cancelScheduledValues(this.context.currentTime); - this.master.gain.linearRampToValueAtTime(muted ? 0 : 0.34, this.context.currentTime + 0.18); + this.master.gain.linearRampToValueAtTime(muted ? 0 : 0.3, this.context.currentTime + 0.18); } isMuted() {