Remove TTS narration and improve BGM
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '의용군은 마을 어귀로 향했다. 첫 싸움은 곧 시작된다.'
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user