Remove TTS narration and improve BGM

This commit is contained in:
2026-06-22 00:26:28 +09:00
parent e45ad9e5ea
commit cc554ccf21
25 changed files with 347 additions and 414 deletions

View File

@@ -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

View File

@@ -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"
},

View File

@@ -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) {

View File

@@ -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}`);
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,18 +1,4 @@
type AudioTrackMap = Record<string, string>;
export type VoiceRole = 'narrator' | 'liuBei' | 'guanYu' | 'zhangFei';
type PitchSafeAudio = HTMLAudioElement & {
preservesPitch?: boolean;
mozPreservesPitch?: boolean;
webkitPreservesPitch?: boolean;
};
const rolePlayback: Record<VoiceRole, { rate: number; volume: number }> = {
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 {

View File

@@ -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;

View File

@@ -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: '의용군은 마을 어귀로 향했다. 첫 싸움은 곧 시작된다.'

View File

@@ -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();

View File

@@ -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);

View File

@@ -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);