Add music and voice assets
This commit is contained in:
@@ -1,42 +1,82 @@
|
||||
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;
|
||||
private master?: GainNode;
|
||||
private musicGain?: GainNode;
|
||||
private started = false;
|
||||
private muted = false;
|
||||
private sequenceTimer?: number;
|
||||
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;
|
||||
|
||||
registerMusicTracks(tracks: AudioTrackMap) {
|
||||
this.musicTracks = tracks;
|
||||
}
|
||||
|
||||
registerVoiceTracks(tracks: AudioTrackMap) {
|
||||
this.voiceTracks = tracks;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.started) {
|
||||
this.resume();
|
||||
if (this.pendingMusicKey) {
|
||||
this.playMusic(this.pendingMusicKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const AudioContextCtor = window.AudioContext ?? window.webkitAudioContext;
|
||||
if (!AudioContextCtor) {
|
||||
return;
|
||||
if (AudioContextCtor) {
|
||||
this.context = new AudioContextCtor();
|
||||
this.master = this.context.createGain();
|
||||
this.master.gain.value = this.muted ? 0 : 0.34;
|
||||
this.master.connect(this.context.destination);
|
||||
}
|
||||
|
||||
this.context = new AudioContextCtor();
|
||||
this.master = this.context.createGain();
|
||||
this.master.gain.value = this.muted ? 0 : 0.34;
|
||||
this.master.connect(this.context.destination);
|
||||
|
||||
this.musicGain = this.context.createGain();
|
||||
this.musicGain.gain.value = 0.0;
|
||||
this.musicGain.connect(this.master);
|
||||
this.started = true;
|
||||
|
||||
this.createAmbientBed();
|
||||
this.schedulePlucks();
|
||||
this.musicGain.gain.linearRampToValueAtTime(0.72, this.context.currentTime + 2.4);
|
||||
this.resume();
|
||||
if (this.pendingMusicKey) {
|
||||
this.playMusic(this.pendingMusicKey);
|
||||
}
|
||||
}
|
||||
|
||||
resume() {
|
||||
void this.context?.resume();
|
||||
if (this.started && this.music) {
|
||||
void this.music.play().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
setMuted(muted: boolean) {
|
||||
this.muted = muted;
|
||||
if (this.music) {
|
||||
this.music.muted = muted;
|
||||
}
|
||||
if (this.voice) {
|
||||
this.voice.muted = muted;
|
||||
}
|
||||
|
||||
if (!this.context || !this.master) {
|
||||
return;
|
||||
@@ -59,60 +99,110 @@ class SoundDirector {
|
||||
window.setTimeout(() => this.playTone(495, 0.1, 0.08, 'triangle'), 65);
|
||||
}
|
||||
|
||||
private createAmbientBed() {
|
||||
if (!this.context || !this.musicGain) {
|
||||
playMusic(key?: string) {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const base = this.context.createOscillator();
|
||||
const fifth = this.context.createOscillator();
|
||||
const filter = this.context.createBiquadFilter();
|
||||
const bedGain = this.context.createGain();
|
||||
this.pendingMusicKey = key;
|
||||
if (!this.started) {
|
||||
return;
|
||||
}
|
||||
|
||||
base.type = 'sine';
|
||||
fifth.type = 'sine';
|
||||
base.frequency.value = 110;
|
||||
fifth.frequency.value = 165;
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.value = 520;
|
||||
bedGain.gain.value = 0.08;
|
||||
const src = this.musicTracks[key];
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
|
||||
base.connect(filter);
|
||||
fifth.connect(filter);
|
||||
filter.connect(bedGain);
|
||||
bedGain.connect(this.musicGain);
|
||||
base.start();
|
||||
fifth.start();
|
||||
if (this.currentMusicKey === key && this.music) {
|
||||
this.music.muted = this.muted;
|
||||
void this.music.play().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = this.music;
|
||||
const next = new Audio(src);
|
||||
next.loop = true;
|
||||
next.preload = 'auto';
|
||||
next.muted = this.muted;
|
||||
next.volume = 0;
|
||||
|
||||
this.music = next;
|
||||
this.currentMusicKey = key;
|
||||
void next.play().catch(() => undefined);
|
||||
this.fadeMusic(previous, next, this.voice ? this.duckedMusicVolume : this.musicVolume);
|
||||
}
|
||||
|
||||
private schedulePlucks() {
|
||||
if (!this.context) {
|
||||
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 notes = [220, 247, 294, 330, 392, 330, 294, 247];
|
||||
let index = 0;
|
||||
this.sequenceTimer = window.setInterval(() => {
|
||||
this.playTone(notes[index % notes.length], 0.18, 0.09, 'triangle', true);
|
||||
index += 1;
|
||||
}, 1800);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private playTone(
|
||||
frequency: number,
|
||||
duration: number,
|
||||
gainValue: number,
|
||||
type: OscillatorType,
|
||||
routeToMusic = false
|
||||
type: OscillatorType
|
||||
) {
|
||||
if (!this.context) {
|
||||
if (!this.context || this.muted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oscillator = this.context.createOscillator();
|
||||
const gain = this.context.createGain();
|
||||
const now = this.context.currentTime;
|
||||
const destination = routeToMusic && this.musicGain ? this.musicGain : this.master;
|
||||
|
||||
oscillator.type = type;
|
||||
oscillator.frequency.value = frequency;
|
||||
@@ -121,10 +211,73 @@ class SoundDirector {
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
|
||||
|
||||
oscillator.connect(gain);
|
||||
gain.connect(destination ?? this.context.destination);
|
||||
gain.connect(this.master ?? this.context.destination);
|
||||
oscillator.start(now);
|
||||
oscillator.stop(now + duration + 0.04);
|
||||
}
|
||||
|
||||
private fadeMusic(previous: HTMLAudioElement | undefined, next: HTMLAudioElement, targetVolume: number) {
|
||||
if (this.musicFadeTimer) {
|
||||
window.clearInterval(this.musicFadeTimer);
|
||||
}
|
||||
|
||||
const duration = 900;
|
||||
const startedAt = performance.now();
|
||||
const previousStartVolume = previous?.volume ?? 0;
|
||||
|
||||
this.musicFadeTimer = window.setInterval(() => {
|
||||
const progress = Math.min(1, (performance.now() - startedAt) / duration);
|
||||
next.volume = targetVolume * progress;
|
||||
|
||||
if (previous) {
|
||||
previous.volume = previousStartVolume * (1 - progress);
|
||||
}
|
||||
|
||||
if (progress >= 1) {
|
||||
if (this.musicFadeTimer) {
|
||||
window.clearInterval(this.musicFadeTimer);
|
||||
this.musicFadeTimer = undefined;
|
||||
}
|
||||
if (previous) {
|
||||
previous.pause();
|
||||
previous.src = '';
|
||||
}
|
||||
}
|
||||
}, 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 {
|
||||
|
||||
Reference in New Issue
Block a user