diff --git a/docs/roadmap.md b/docs/roadmap.md index 805a9e8..412e7fa 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, and scenario data -- File-based original BGM loops, local generated Korean narration/voice lines, and automatic music ducking during speech +- File-based original orchestral BGM loops for title, prologue, oath, militia, and battle-prep scenes - First battle scene with a 12x12 map, terrain colors, unit placement, and movement range preview - Flow verification script from title to first battle diff --git a/package.json b/package.json index 836a77d..7a2105d 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite --host 0.0.0.0", "build": "tsc && vite build", - "generate:audio": "node scripts/generate-bgm.mjs && node scripts/generate-voice-assets.mjs", + "generate:audio": "node scripts/generate-bgm.mjs", "preview": "vite preview --host 0.0.0.0", "verify:flow": "node scripts/verify-flow.mjs" }, diff --git a/scripts/generate-bgm.mjs b/scripts/generate-bgm.mjs index 95a8320..6d5a9d9 100644 --- a/scripts/generate-bgm.mjs +++ b/scripts/generate-bgm.mjs @@ -1,8 +1,7 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -const sampleRate = 22050; -const duration = 32; +const sampleRate = 32000; const outputDir = 'src/assets/audio/bgm'; mkdirSync(outputDir, { recursive: true }); @@ -10,103 +9,308 @@ mkdirSync(outputDir, { recursive: true }); const tracks = [ { name: 'title-theme', - root: 196, - tempo: 72, - drum: 0.34, - melody: [0, 2, 4, 7, 9, 7, 4, 2], - bass: [0, 0, -5, -3], - color: 'warm' + 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: 146.83, - tempo: 58, - drum: 0.22, - melody: [0, -3, 0, 2, -5, -3, 0, -7], - bass: [0, -7, -5, -3], + 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: 220, - tempo: 66, - drum: 0.24, - melody: [0, 4, 7, 9, 12, 9, 7, 4], - bass: [0, -5, -3, -7], + 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: 174.61, - tempo: 82, - drum: 0.42, - melody: [0, 2, 5, 7, 9, 7, 5, 2], - bass: [0, -5, 0, -3], - color: 'earth' + 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: 164.81, - tempo: 94, - drum: 0.5, - melody: [0, 3, 5, 7, 10, 7, 5, 3], - bass: [0, 0, -2, -5], + 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 = new Float32Array(sampleRate * duration); - buildTrack(buffer, track); - normalize(buffer, 0.86); - writeFileSync(join(outputDir, `${track.name}.wav`), encodeWav(buffer)); + 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(buffer, track) { +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 t = 0; t < duration; t += bar) { - for (let i = 0; i < 4; i += 1) { - const bassFreq = note(track.root, track.bass[i % track.bass.length] - 12); - addTone(buffer, t + i * beat, beat * 1.9, bassFreq, 0.075, 'soft'); - addTone(buffer, t + i * beat, beat * 1.9, bassFreq * 2, 0.025, 'soft'); + 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); } } - for (let t = 0; t < duration; t += beat * 2) { - const step = Math.floor(t / (beat * 2)) % track.melody.length; - const freq = note(track.root, track.melody[step]); - addPluck(buffer, t + 0.04, beat * 1.65, freq, 0.12, track.color); - addPluck(buffer, t + beat * 0.95, beat * 0.9, freq * 1.5, 0.045, track.color); + 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; } - for (let t = 0; t < duration; t += beat) { - if (Math.round(t / beat) % 4 === 0) { - addDrum(buffer, t, 0.26, track.drum); - } else if (track.drum > 0.3 && Math.round(t / beat) % 2 === 0) { - addDrum(buffer, t, 0.16, track.drum * 0.45); + 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; } - } - for (let t = 0; t < duration; t += bar * 2) { - addNoiseWash(buffer, t, bar * 1.8, track.color === 'dark' ? 0.012 : 0.007); + 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) { - const seconds = i / sampleRate; - const edge = Math.min(1, seconds / 0.6, (duration - seconds) / 0.6); - buffer[i] *= Math.max(0, edge); + low = low * 0.996 + noise(i + 991) * 0.004; + buffer[i] += low * gain; } } -function note(root, semitone) { - return root * 2 ** (semitone / 12); -} - -function addTone(buffer, start, length, freq, gain, shape) { - const startIndex = Math.floor(start * sampleRate); - const count = Math.floor(length * sampleRate); +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; @@ -115,72 +319,92 @@ function addTone(buffer, start, length, freq, gain, shape) { } const t = i / sampleRate; - const env = Math.sin(Math.PI * Math.min(1, i / count)); - const wave = Math.sin(2 * Math.PI * freq * t) + Math.sin(2 * Math.PI * freq * 2 * t) * 0.18; - buffer[index] += wave * gain * env * (shape === 'soft' ? 0.78 : 1); - } -} - -function addPluck(buffer, start, length, freq, gain, color) { - const startIndex = Math.floor(start * sampleRate); - const count = Math.floor(length * sampleRate); - const brightness = color === 'gold' ? 0.36 : color === 'steel' ? 0.42 : 0.28; - - 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 * 4.8) * Math.min(1, t / 0.02); - const wave = - Math.sin(2 * Math.PI * freq * t) + - Math.sin(2 * Math.PI * freq * 2 * t) * brightness + - Math.sin(2 * Math.PI * freq * 3 * t) * 0.14; + 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 addDrum(buffer, start, length, gain) { - const startIndex = Math.floor(start * sampleRate); - const count = Math.floor(length * sampleRate); - let seed = 1337 + startIndex; +function instrumentWave(profile, frequency, t, index) { + const phase = 2 * Math.PI * frequency * t; - for (let i = 0; i < count; i += 1) { - const index = startIndex + i; - if (index >= buffer.length) { - break; - } + 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 + ); + } - const t = i / sampleRate; - const env = Math.exp(-t * 15); - const freq = 96 - t * 120; - seed = (seed * 1664525 + 1013904223) >>> 0; - const noise = ((seed / 0xffffffff) * 2 - 1) * 0.18; - const wave = Math.sin(2 * Math.PI * Math.max(42, freq) * t) + noise; - buffer[index] += wave * gain * env; + 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 addNoiseWash(buffer, start, length, gain) { - const startIndex = Math.floor(start * sampleRate); - const count = Math.floor(length * sampleRate); - let seed = 4219 + startIndex; - let previous = 0; +function midiToFrequency(midi) { + return 440 * 2 ** ((midi - 69) / 12); +} - for (let i = 0; i < count; i += 1) { - const index = startIndex + i; - if (index >= buffer.length) { - break; - } - - seed = (seed * 1103515245 + 12345) >>> 0; - const raw = (seed / 0xffffffff) * 2 - 1; - previous = previous * 0.98 + raw * 0.02; - const env = Math.sin(Math.PI * i / count); - buffer[index] += previous * gain * env; - } +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) { diff --git a/scripts/generate-voice-assets.mjs b/scripts/generate-voice-assets.mjs deleted file mode 100644 index 430c3e1..0000000 --- a/scripts/generate-voice-assets.mjs +++ /dev/null @@ -1,127 +0,0 @@ -import { mkdirSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { spawnSync } from 'node:child_process'; - -const outputDir = resolve('src/assets/audio/voice/prologue'); - -mkdirSync(outputDir, { recursive: true }); - -const voiceName = 'Microsoft Heami Desktop'; - -const lines = [ - { - name: 'yellow-turban-chaos', - role: 'narrator', - rate: -2, - volume: 86, - text: '중평 원년, 황건의 깃발이 각지에서 일어섰다. 관군은 흩어지고, 마을마다 불안한 소문이 번져갔다.' - }, - { - name: 'liu-bei-resolve', - role: 'liuBei', - rate: -1, - volume: 92, - text: '천실의 백성이 이토록 고통받는데, 어찌 초개처럼 그늘에 숨어 있을 수 있겠는가.' - }, - { - name: 'zhang-fei-joins', - role: 'zhangFei', - rate: 1, - volume: 100, - text: '좋소! 나라를 바로잡고 백성을 구한다면, 내 재산과 힘을 모두 아끼지 않겠소.' - }, - { - name: 'guan-yu-joins', - role: 'guanYu', - rate: -3, - volume: 95, - text: '서로의 뜻을 믿고 의로운 길을 함께한다면, 천 리라도 멀다 하지 않겠습니다.' - }, - { - name: 'oath-narration', - role: 'narrator', - rate: -2, - volume: 88, - text: '복사꽃 향기가 흩날리는 동산에서 세 사람은 하늘과 땅 앞에 고했다.' - }, - { - name: 'oath-liu-bei', - role: 'liuBei', - rate: -1, - volume: 94, - text: '우리는 태어난 날은 달라도, 오늘부터 뜻을 함께한다. 백성을 구하고 천하를 바로 세우는 길에서 물러서지 않겠다.' - }, - { - name: 'oath-brothers', - role: 'guanYu', - rate: -3, - volume: 96, - text: '의리를 저버리지 않고, 형제를 저버리지 않겠습니다.' - }, - { - name: 'militia-rises', - role: 'zhangFei', - rate: 1, - volume: 100, - text: '창을 들 사람은 앞으로 나오시오! 오늘 모인 이름 없는 무리가 내일의 방패가 될 것이오!' - }, - { - name: 'first-sortie', - role: 'liuBei', - rate: -1, - volume: 94, - text: '우리는 아직 작다. 그러나 오늘 한 마을을 지켜낸다면, 그 뜻은 천하로 번져갈 것이다.' - }, - { - name: 'battle-briefing', - role: 'narrator', - rate: -2, - volume: 88, - text: '의용군은 마을 입구로 향했다. 첫 전투가 곧 시작된다.' - } -]; - -for (const line of lines) { - const output = resolve(outputDir, `${line.name}.wav`); - generateVoice({ ...line, output }); -} - -function generateVoice(item) { - const payload = Buffer.from(JSON.stringify({ ...item, voiceName }), 'utf8').toString('base64'); - const powershell = ` -$ErrorActionPreference = 'Stop' -$ProgressPreference = 'SilentlyContinue' -Add-Type -AssemblyName System.Speech -$payload = '${payload}' -$json = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($payload)) -$item = $json | ConvertFrom-Json -$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer -try { - try { - $synth.SelectVoice($item.voiceName) - } catch { - $installed = $synth.GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name } - if ($installed.Count -gt 0) { - $synth.SelectVoice($installed[0]) - } - } - $synth.Rate = [int]$item.rate - $synth.Volume = [int]$item.volume - $synth.SetOutputToWaveFile($item.output) - $synth.Speak($item.text) -} finally { - $synth.Dispose() -} -`; - - const encodedCommand = Buffer.from(powershell, 'utf16le').toString('base64'); - const result = spawnSync( - 'powershell.exe', - ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encodedCommand], - { stdio: 'inherit' } - ); - - if (result.status !== 0) { - throw new Error(`Failed to generate voice asset: ${item.name}`); - } -} diff --git a/src/assets/audio/bgm/battle-prep.wav b/src/assets/audio/bgm/battle-prep.wav index e5cf201..8f48e5f 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 b2acdf1..1d036e0 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 b3abc29..6fa1a14 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 c48e7d3..63c94ad 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 4b1456c..da85c09 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/assets/audio/voice/prologue/battle-briefing.wav b/src/assets/audio/voice/prologue/battle-briefing.wav deleted file mode 100644 index 7cf1e5c..0000000 Binary files a/src/assets/audio/voice/prologue/battle-briefing.wav and /dev/null differ diff --git a/src/assets/audio/voice/prologue/first-sortie.wav b/src/assets/audio/voice/prologue/first-sortie.wav deleted file mode 100644 index a897440..0000000 Binary files a/src/assets/audio/voice/prologue/first-sortie.wav and /dev/null differ diff --git a/src/assets/audio/voice/prologue/guan-yu-joins.wav b/src/assets/audio/voice/prologue/guan-yu-joins.wav deleted file mode 100644 index 8b13ec6..0000000 Binary files a/src/assets/audio/voice/prologue/guan-yu-joins.wav and /dev/null differ diff --git a/src/assets/audio/voice/prologue/liu-bei-resolve.wav b/src/assets/audio/voice/prologue/liu-bei-resolve.wav deleted file mode 100644 index b76dc70..0000000 Binary files a/src/assets/audio/voice/prologue/liu-bei-resolve.wav and /dev/null differ diff --git a/src/assets/audio/voice/prologue/militia-rises.wav b/src/assets/audio/voice/prologue/militia-rises.wav deleted file mode 100644 index 952dfa6..0000000 Binary files a/src/assets/audio/voice/prologue/militia-rises.wav and /dev/null differ diff --git a/src/assets/audio/voice/prologue/oath-brothers.wav b/src/assets/audio/voice/prologue/oath-brothers.wav deleted file mode 100644 index 92ea777..0000000 Binary files a/src/assets/audio/voice/prologue/oath-brothers.wav and /dev/null differ diff --git a/src/assets/audio/voice/prologue/oath-liu-bei.wav b/src/assets/audio/voice/prologue/oath-liu-bei.wav deleted file mode 100644 index 3df5802..0000000 Binary files a/src/assets/audio/voice/prologue/oath-liu-bei.wav and /dev/null differ diff --git a/src/assets/audio/voice/prologue/oath-narration.wav b/src/assets/audio/voice/prologue/oath-narration.wav deleted file mode 100644 index 2199a55..0000000 Binary files a/src/assets/audio/voice/prologue/oath-narration.wav and /dev/null differ diff --git a/src/assets/audio/voice/prologue/yellow-turban-chaos.wav b/src/assets/audio/voice/prologue/yellow-turban-chaos.wav deleted file mode 100644 index 6cd689c..0000000 Binary files a/src/assets/audio/voice/prologue/yellow-turban-chaos.wav and /dev/null differ diff --git a/src/assets/audio/voice/prologue/zhang-fei-joins.wav b/src/assets/audio/voice/prologue/zhang-fei-joins.wav deleted file mode 100644 index 46f21a0..0000000 Binary files a/src/assets/audio/voice/prologue/zhang-fei-joins.wav and /dev/null differ diff --git a/src/game/audio/SoundDirector.ts b/src/game/audio/SoundDirector.ts index 048fc3e..0d87452 100644 --- a/src/game/audio/SoundDirector.ts +++ b/src/game/audio/SoundDirector.ts @@ -1,18 +1,4 @@ type AudioTrackMap = Record; -export type VoiceRole = 'narrator' | 'liuBei' | 'guanYu' | 'zhangFei'; - -type PitchSafeAudio = HTMLAudioElement & { - preservesPitch?: boolean; - mozPreservesPitch?: boolean; - webkitPreservesPitch?: boolean; -}; - -const rolePlayback: Record = { - narrator: { rate: 0.98, volume: 0.9 }, - liuBei: { rate: 0.96, volume: 0.92 }, - guanYu: { rate: 0.84, volume: 0.95 }, - zhangFei: { rate: 1.05, volume: 1 } -}; class SoundDirector { private context?: AudioContext; @@ -20,24 +6,16 @@ class SoundDirector { private started = false; private muted = false; private musicTracks: AudioTrackMap = {}; - private voiceTracks: AudioTrackMap = {}; private music?: HTMLAudioElement; - private voice?: HTMLAudioElement; private currentMusicKey?: string; private pendingMusicKey?: string; private musicFadeTimer?: number; - private musicRampTimer?: number; - private readonly musicVolume = 0.46; - private readonly duckedMusicVolume = 0.18; + private readonly musicVolume = 0.56; registerMusicTracks(tracks: AudioTrackMap) { this.musicTracks = tracks; } - registerVoiceTracks(tracks: AudioTrackMap) { - this.voiceTracks = tracks; - } - start() { if (this.started) { this.resume(); @@ -74,9 +52,6 @@ class SoundDirector { if (this.music) { this.music.muted = muted; } - if (this.voice) { - this.voice.muted = muted; - } if (!this.context || !this.master) { return; @@ -130,64 +105,7 @@ class SoundDirector { this.music = next; this.currentMusicKey = key; void next.play().catch(() => undefined); - this.fadeMusic(previous, next, this.voice ? this.duckedMusicVolume : this.musicVolume); - } - - playVoice(key?: string, role: VoiceRole = 'narrator') { - if (!key || !this.started) { - this.stopVoice(); - return; - } - this.stopVoice(false); - - const src = this.voiceTracks[key]; - if (!src) { - return; - } - - const profile = rolePlayback[role] ?? rolePlayback.narrator; - const voice = new Audio(src) as PitchSafeAudio; - voice.preload = 'auto'; - voice.muted = this.muted; - voice.volume = profile.volume; - voice.playbackRate = profile.rate; - voice.preservesPitch = false; - voice.mozPreservesPitch = false; - voice.webkitPreservesPitch = false; - voice.addEventListener('ended', () => { - if (this.voice === voice) { - this.voice = undefined; - this.duckMusic(false); - } - }); - voice.addEventListener('error', () => { - if (this.voice === voice) { - this.voice = undefined; - this.duckMusic(false); - } - }); - - this.voice = voice; - this.duckMusic(true); - void voice.play().catch(() => { - if (this.voice === voice) { - this.voice = undefined; - this.duckMusic(false); - } - }); - } - - stopVoice(restoreMusic = true) { - if (!this.voice) { - return; - } - - this.voice.pause(); - this.voice.currentTime = 0; - this.voice = undefined; - if (restoreMusic) { - this.duckMusic(false); - } + this.fadeMusic(previous, next, this.musicVolume); } private playTone( @@ -246,38 +164,6 @@ class SoundDirector { }, 32); } - private duckMusic(ducked: boolean) { - if (!this.music) { - return; - } - - if (ducked && this.musicFadeTimer) { - window.clearInterval(this.musicFadeTimer); - this.musicFadeTimer = undefined; - } - - if (this.musicRampTimer) { - window.clearInterval(this.musicRampTimer); - } - - const from = this.music.volume; - const to = ducked ? this.duckedMusicVolume : this.musicVolume; - const duration = 260; - const startedAt = performance.now(); - - this.musicRampTimer = window.setInterval(() => { - if (!this.music) { - return; - } - - const progress = Math.min(1, (performance.now() - startedAt) / duration); - this.music.volume = from + (to - from) * progress; - if (progress >= 1 && this.musicRampTimer) { - window.clearInterval(this.musicRampTimer); - this.musicRampTimer = undefined; - } - }, 32); - } } declare global { diff --git a/src/game/audio/audioAssets.ts b/src/game/audio/audioAssets.ts index ac864ff..2ddc3ce 100644 --- a/src/game/audio/audioAssets.ts +++ b/src/game/audio/audioAssets.ts @@ -3,16 +3,6 @@ import militiaThemeUrl from '../../assets/audio/bgm/militia-theme.wav'; import oathThemeUrl from '../../assets/audio/bgm/oath-theme.wav'; import storyDarkUrl from '../../assets/audio/bgm/story-dark.wav'; import titleThemeUrl from '../../assets/audio/bgm/title-theme.wav'; -import battleBriefingVoiceUrl from '../../assets/audio/voice/prologue/battle-briefing.wav'; -import firstSortieVoiceUrl from '../../assets/audio/voice/prologue/first-sortie.wav'; -import guanYuJoinsVoiceUrl from '../../assets/audio/voice/prologue/guan-yu-joins.wav'; -import liuBeiResolveVoiceUrl from '../../assets/audio/voice/prologue/liu-bei-resolve.wav'; -import militiaRisesVoiceUrl from '../../assets/audio/voice/prologue/militia-rises.wav'; -import oathBrothersVoiceUrl from '../../assets/audio/voice/prologue/oath-brothers.wav'; -import oathLiuBeiVoiceUrl from '../../assets/audio/voice/prologue/oath-liu-bei.wav'; -import oathNarrationVoiceUrl from '../../assets/audio/voice/prologue/oath-narration.wav'; -import yellowTurbanChaosVoiceUrl from '../../assets/audio/voice/prologue/yellow-turban-chaos.wav'; -import zhangFeiJoinsVoiceUrl from '../../assets/audio/voice/prologue/zhang-fei-joins.wav'; export const musicTracks = { 'title-theme': titleThemeUrl, @@ -22,18 +12,4 @@ export const musicTracks = { 'battle-prep': battlePrepUrl } as const; -export const voiceTracks = { - 'yellow-turban-chaos': yellowTurbanChaosVoiceUrl, - 'liu-bei-resolve': liuBeiResolveVoiceUrl, - 'zhang-fei-joins': zhangFeiJoinsVoiceUrl, - 'guan-yu-joins': guanYuJoinsVoiceUrl, - 'oath-narration': oathNarrationVoiceUrl, - 'oath-liu-bei': oathLiuBeiVoiceUrl, - 'oath-brothers': oathBrothersVoiceUrl, - 'militia-rises': militiaRisesVoiceUrl, - 'first-sortie': firstSortieVoiceUrl, - 'battle-briefing': battleBriefingVoiceUrl -} as const; - export type MusicTrackKey = keyof typeof musicTracks; -export type VoiceTrackKey = keyof typeof voiceTracks; diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index 5db25f9..32b774b 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -1,5 +1,4 @@ export type PortraitKey = 'liuBei' | 'guanYu' | 'zhangFei'; -export type VoiceRole = 'narrator' | PortraitKey; export type StoryPage = { id: string; @@ -8,8 +7,6 @@ export type StoryPage = { speaker?: string; portrait?: PortraitKey; bgm?: string; - voice?: string; - voiceRole?: VoiceRole; text: string; }; @@ -38,8 +35,6 @@ export const prologuePages: StoryPage[] = [ { id: 'yellow-turban-chaos', bgm: 'story-dark', - voice: 'yellow-turban-chaos', - voiceRole: 'narrator', chapter: '난세의 시작', background: 'story-chaos', text: '중평 원년, 황건의 깃발이 각지에서 일어났다. 관군은 흩어지고, 마을마다 피난 행렬이 이어졌다.' @@ -47,8 +42,6 @@ export const prologuePages: StoryPage[] = [ { id: 'liu-bei-resolve', bgm: 'story-dark', - voice: 'liu-bei-resolve', - voiceRole: 'liuBei', chapter: '탁현의 방', background: 'story-liu-bei', speaker: '유비', @@ -58,8 +51,6 @@ export const prologuePages: StoryPage[] = [ { id: 'zhang-fei-joins', bgm: 'story-dark', - voice: 'zhang-fei-joins', - voiceRole: 'zhangFei', chapter: '뜻이 모이다', background: 'story-three-heroes', speaker: '장비', @@ -69,8 +60,6 @@ export const prologuePages: StoryPage[] = [ { id: 'guan-yu-joins', bgm: 'story-dark', - voice: 'guan-yu-joins', - voiceRole: 'guanYu', chapter: '뜻이 모이다', background: 'story-three-heroes', speaker: '관우', @@ -80,8 +69,6 @@ export const prologuePages: StoryPage[] = [ { id: 'oath-narration', bgm: 'oath-theme', - voice: 'oath-narration', - voiceRole: 'narrator', chapter: '도원결의', background: 'title-taoyuan', text: '복숭아꽃이 흩날리는 동산에서 세 사람은 하늘과 땅에 고했다.' @@ -89,8 +76,6 @@ export const prologuePages: StoryPage[] = [ { id: 'oath-liu-bei', bgm: 'oath-theme', - voice: 'oath-liu-bei', - voiceRole: 'liuBei', chapter: '도원결의', background: 'title-taoyuan', speaker: '유비', @@ -100,8 +85,6 @@ export const prologuePages: StoryPage[] = [ { id: 'oath-brothers', bgm: 'oath-theme', - voice: 'oath-brothers', - voiceRole: 'guanYu', chapter: '도원결의', background: 'title-taoyuan', speaker: '관우', @@ -111,8 +94,6 @@ export const prologuePages: StoryPage[] = [ { id: 'militia-rises', bgm: 'militia-theme', - voice: 'militia-rises', - voiceRole: 'zhangFei', chapter: '의용군 모집', background: 'story-militia', speaker: '장비', @@ -122,8 +103,6 @@ export const prologuePages: StoryPage[] = [ { id: 'first-sortie', bgm: 'battle-prep', - voice: 'first-sortie', - voiceRole: 'liuBei', chapter: '첫 출진', background: 'story-sortie', speaker: '유비', @@ -133,8 +112,6 @@ export const prologuePages: StoryPage[] = [ { id: 'battle-briefing', bgm: 'battle-prep', - voice: 'battle-briefing', - voiceRole: 'narrator', chapter: '황건적 토벌', background: 'story-sortie', text: '의용군은 마을 어귀로 향했다. 첫 싸움은 곧 시작된다.' diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 746cf0d..990c6ab 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -25,7 +25,6 @@ export class BattleScene extends Phaser.Scene { create() { const { width, height } = this.scale; - soundDirector.stopVoice(); soundDirector.playMusic('battle-prep'); this.add.rectangle(0, 0, width, height, 0x0f1418).setOrigin(0); this.drawMap(); diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index 8756eae..784d616 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -116,7 +116,6 @@ export class StoryScene extends Phaser.Scene { private applyPage(page: StoryPage) { soundDirector.playMusic(page.bgm); - soundDirector.playVoice(page.voice, page.voiceRole ?? page.portrait ?? 'narrator'); this.applyBackground(page.background); this.chapterText?.setText(page.chapter); diff --git a/src/main.ts b/src/main.ts index 1600ecf..e5b1809 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import Phaser from 'phaser'; -import { musicTracks, voiceTracks } from './game/audio/audioAssets'; +import { musicTracks } from './game/audio/audioAssets'; import { soundDirector } from './game/audio/SoundDirector'; import { BattleScene } from './game/scenes/BattleScene'; import { BootScene } from './game/scenes/BootScene'; @@ -27,7 +27,6 @@ const config: Phaser.Types.Core.GameConfig = { }; soundDirector.registerMusicTracks(musicTracks); -soundDirector.registerVoiceTracks(voiceTracks); const game = new Phaser.Game(config);