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

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