feat: add layered soundscape channels
This commit is contained in:
@@ -1,29 +1,66 @@
|
||||
type AudioTrackMap = Record<string, string>;
|
||||
|
||||
export type SoundscapeOptions = {
|
||||
musicKey?: string;
|
||||
ambienceKey?: string;
|
||||
musicVolume?: number;
|
||||
ambienceVolume?: number;
|
||||
};
|
||||
|
||||
type LoopChannelName = 'music' | 'ambience';
|
||||
|
||||
type LoopChannelTransition = {
|
||||
revision: number;
|
||||
completedRevision: number;
|
||||
active: boolean;
|
||||
last?: {
|
||||
fromKey: string | null;
|
||||
toKey: string | null;
|
||||
durationMs: number;
|
||||
};
|
||||
};
|
||||
|
||||
type LoopChannel = {
|
||||
name: LoopChannelName;
|
||||
tracks: AudioTrackMap;
|
||||
defaultVolume: number;
|
||||
targetVolume: number;
|
||||
fadeDurationMs: number;
|
||||
current?: HTMLAudioElement;
|
||||
currentKey?: string;
|
||||
pendingKey?: string;
|
||||
hasPendingRequest: boolean;
|
||||
outgoing: Set<HTMLAudioElement>;
|
||||
outgoingStartVolumes: Map<HTMLAudioElement, number>;
|
||||
fadeTimer?: number;
|
||||
elementRevision: number;
|
||||
transition: LoopChannelTransition;
|
||||
};
|
||||
|
||||
type PlayEffectOptions = {
|
||||
volume?: number;
|
||||
rate?: number;
|
||||
stopAfterMs?: number;
|
||||
};
|
||||
|
||||
class SoundDirector {
|
||||
export class SoundDirector {
|
||||
private context?: AudioContext;
|
||||
private master?: GainNode;
|
||||
private started = false;
|
||||
private muted = false;
|
||||
private musicTracks: AudioTrackMap = {};
|
||||
private effectTracks: AudioTrackMap = {};
|
||||
private music?: HTMLAudioElement;
|
||||
private currentMusicKey?: string;
|
||||
private pendingMusicKey?: string;
|
||||
private musicFadeTimer?: number;
|
||||
private readonly musicChannel = this.createLoopChannel('music', 0.22, 900);
|
||||
private readonly ambienceChannel = this.createLoopChannel('ambience', 0.08, 1200);
|
||||
private lastEffect?: { key: string; at: number; volume: number; rate: number };
|
||||
private lastMovementStep?: { key: string; mounted: boolean; stepIndex: number; at: number; volume?: number; rate?: number };
|
||||
private readonly musicVolume = 0.22;
|
||||
private readonly effectVolume = 0.42;
|
||||
|
||||
registerMusicTracks(tracks: AudioTrackMap) {
|
||||
this.musicTracks = tracks;
|
||||
this.registerLoopTracks(this.musicChannel, tracks);
|
||||
}
|
||||
|
||||
registerAmbienceTracks(tracks: AudioTrackMap) {
|
||||
this.registerLoopTracks(this.ambienceChannel, tracks);
|
||||
}
|
||||
|
||||
registerEffectTracks(tracks: AudioTrackMap) {
|
||||
@@ -33,9 +70,8 @@ class SoundDirector {
|
||||
start() {
|
||||
if (this.started) {
|
||||
this.resume();
|
||||
if (this.pendingMusicKey) {
|
||||
this.playMusic(this.pendingMusicKey);
|
||||
}
|
||||
this.applyPendingLoopRequest(this.musicChannel);
|
||||
this.applyPendingLoopRequest(this.ambienceChannel);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -49,22 +85,25 @@ class SoundDirector {
|
||||
|
||||
this.started = true;
|
||||
this.resume();
|
||||
if (this.pendingMusicKey) {
|
||||
this.playMusic(this.pendingMusicKey);
|
||||
}
|
||||
this.applyPendingLoopRequest(this.musicChannel);
|
||||
this.applyPendingLoopRequest(this.ambienceChannel);
|
||||
}
|
||||
|
||||
resume() {
|
||||
void this.context?.resume();
|
||||
if (this.started && this.music) {
|
||||
void this.music.play().catch(() => undefined);
|
||||
if (!this.started) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const element of this.loopElements()) {
|
||||
void element.play().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
setMuted(muted: boolean) {
|
||||
this.muted = muted;
|
||||
if (this.music) {
|
||||
this.music.muted = muted;
|
||||
for (const element of this.loopElements()) {
|
||||
element.muted = muted;
|
||||
}
|
||||
|
||||
if (!this.context || !this.master) {
|
||||
@@ -200,12 +239,19 @@ class SoundDirector {
|
||||
}
|
||||
|
||||
getDebugState() {
|
||||
const music = this.loopChannelDebugState(this.musicChannel);
|
||||
const ambience = this.loopChannelDebugState(this.ambienceChannel);
|
||||
|
||||
return {
|
||||
started: this.started,
|
||||
muted: this.muted,
|
||||
contextState: this.context?.state ?? null,
|
||||
currentMusicKey: this.currentMusicKey ?? null,
|
||||
pendingMusicKey: this.pendingMusicKey ?? null,
|
||||
currentMusicKey: music.currentKey,
|
||||
pendingMusicKey: music.pendingKey,
|
||||
currentAmbienceKey: ambience.currentKey,
|
||||
pendingAmbienceKey: ambience.pendingKey,
|
||||
music,
|
||||
ambience,
|
||||
lastEffect: this.lastEffect ?? null,
|
||||
lastMovementStep: this.lastMovementStep ?? null
|
||||
};
|
||||
@@ -216,33 +262,23 @@ class SoundDirector {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingMusicKey = key;
|
||||
if (!this.started) {
|
||||
return;
|
||||
}
|
||||
this.playSoundscape({ musicKey: key });
|
||||
}
|
||||
|
||||
const src = this.musicTracks[key];
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
playSoundscape({ musicKey, ambienceKey, musicVolume, ambienceVolume }: SoundscapeOptions) {
|
||||
this.setLoopTargetVolume(this.musicChannel, musicVolume ?? this.musicChannel.defaultVolume);
|
||||
this.setLoopTargetVolume(this.ambienceChannel, ambienceVolume ?? this.ambienceChannel.defaultVolume);
|
||||
this.requestLoopChannel(this.musicChannel, musicKey);
|
||||
this.requestLoopChannel(this.ambienceChannel, ambienceKey);
|
||||
}
|
||||
|
||||
if (this.currentMusicKey === key && this.music) {
|
||||
this.music.muted = this.muted;
|
||||
void this.music.play().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
playAmbience(key?: string, volume = this.ambienceChannel.defaultVolume) {
|
||||
this.setLoopTargetVolume(this.ambienceChannel, volume);
|
||||
this.requestLoopChannel(this.ambienceChannel, key);
|
||||
}
|
||||
|
||||
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.musicVolume);
|
||||
stopAmbience() {
|
||||
this.requestLoopChannel(this.ambienceChannel, undefined);
|
||||
}
|
||||
|
||||
private playTone(
|
||||
@@ -316,36 +352,197 @@ class SoundDirector {
|
||||
oscillator.stop(start + duration + 0.025);
|
||||
}
|
||||
|
||||
private fadeMusic(previous: HTMLAudioElement | undefined, next: HTMLAudioElement, targetVolume: number) {
|
||||
if (this.musicFadeTimer) {
|
||||
window.clearInterval(this.musicFadeTimer);
|
||||
private createLoopChannel(name: LoopChannelName, targetVolume: number, fadeDurationMs: number): LoopChannel {
|
||||
return {
|
||||
name,
|
||||
tracks: {},
|
||||
defaultVolume: targetVolume,
|
||||
targetVolume,
|
||||
fadeDurationMs,
|
||||
hasPendingRequest: false,
|
||||
outgoing: new Set(),
|
||||
outgoingStartVolumes: new Map(),
|
||||
elementRevision: 0,
|
||||
transition: {
|
||||
revision: 0,
|
||||
completedRevision: 0,
|
||||
active: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private registerLoopTracks(channel: LoopChannel, tracks: AudioTrackMap) {
|
||||
channel.tracks = tracks;
|
||||
if (this.started) {
|
||||
this.applyPendingLoopRequest(channel);
|
||||
}
|
||||
}
|
||||
|
||||
private setLoopTargetVolume(channel: LoopChannel, volume: number) {
|
||||
channel.targetVolume = Number.isFinite(volume) ? Math.min(1, Math.max(0, volume)) : channel.defaultVolume;
|
||||
if (channel.current && !channel.transition.active) {
|
||||
channel.current.volume = channel.targetVolume;
|
||||
}
|
||||
}
|
||||
|
||||
private requestLoopChannel(channel: LoopChannel, key?: string) {
|
||||
channel.pendingKey = key;
|
||||
channel.hasPendingRequest = true;
|
||||
if (this.started) {
|
||||
this.applyPendingLoopRequest(channel);
|
||||
}
|
||||
}
|
||||
|
||||
private applyPendingLoopRequest(channel: LoopChannel) {
|
||||
if (!channel.hasPendingRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = 900;
|
||||
const key = channel.pendingKey;
|
||||
const requestedTarget = key ?? null;
|
||||
const alreadyTargetingKey =
|
||||
(key !== undefined && channel.currentKey === key && Boolean(channel.current)) ||
|
||||
(key === undefined && !channel.current && !channel.outgoing.size) ||
|
||||
(channel.transition.active && channel.transition.last?.toKey === requestedTarget);
|
||||
|
||||
if (alreadyTargetingKey) {
|
||||
channel.pendingKey = undefined;
|
||||
channel.hasPendingRequest = false;
|
||||
if (channel.current) {
|
||||
channel.current.muted = this.muted;
|
||||
if (!channel.transition.active) {
|
||||
channel.current.volume = channel.targetVolume;
|
||||
}
|
||||
void channel.current.play().catch(() => undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const src = key ? channel.tracks[key] : undefined;
|
||||
if (key && !src) {
|
||||
return;
|
||||
}
|
||||
|
||||
channel.pendingKey = undefined;
|
||||
channel.hasPendingRequest = false;
|
||||
this.beginLoopTransition(channel, key, src);
|
||||
}
|
||||
|
||||
private beginLoopTransition(channel: LoopChannel, key?: string, src?: string) {
|
||||
this.cancelLoopTransitionTimer(channel);
|
||||
this.retireOutgoingElements(channel);
|
||||
|
||||
const previous = channel.current;
|
||||
const fromKey = channel.currentKey ?? null;
|
||||
if (previous) {
|
||||
channel.outgoing.add(previous);
|
||||
channel.outgoingStartVolumes.set(previous, previous.volume);
|
||||
}
|
||||
|
||||
channel.current = undefined;
|
||||
channel.currentKey = undefined;
|
||||
|
||||
if (key && src) {
|
||||
const next = new Audio(src);
|
||||
next.loop = true;
|
||||
next.preload = 'auto';
|
||||
next.muted = this.muted;
|
||||
next.volume = 0;
|
||||
channel.current = next;
|
||||
channel.currentKey = key;
|
||||
channel.elementRevision += 1;
|
||||
void next.play().catch(() => undefined);
|
||||
}
|
||||
|
||||
channel.transition.revision += 1;
|
||||
channel.transition.active = true;
|
||||
channel.transition.last = {
|
||||
fromKey,
|
||||
toKey: key ?? null,
|
||||
durationMs: channel.fadeDurationMs
|
||||
};
|
||||
|
||||
const transitionRevision = channel.transition.revision;
|
||||
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);
|
||||
channel.fadeTimer = window.setInterval(() => {
|
||||
if (transitionRevision !== channel.transition.revision) {
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = Math.min(1, Math.max(0, (performance.now() - startedAt) / channel.fadeDurationMs));
|
||||
if (channel.current) {
|
||||
channel.current.volume = channel.targetVolume * progress;
|
||||
}
|
||||
channel.outgoing.forEach((element) => {
|
||||
const startVolume = channel.outgoingStartVolumes.get(element) ?? element.volume;
|
||||
element.volume = startVolume * (1 - progress);
|
||||
});
|
||||
|
||||
if (progress >= 1) {
|
||||
if (this.musicFadeTimer) {
|
||||
window.clearInterval(this.musicFadeTimer);
|
||||
this.musicFadeTimer = undefined;
|
||||
}
|
||||
if (previous) {
|
||||
previous.pause();
|
||||
previous.src = '';
|
||||
}
|
||||
this.completeLoopTransition(channel, transitionRevision);
|
||||
}
|
||||
}, 32);
|
||||
}
|
||||
|
||||
private completeLoopTransition(channel: LoopChannel, transitionRevision: number) {
|
||||
if (transitionRevision !== channel.transition.revision) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cancelLoopTransitionTimer(channel);
|
||||
this.retireOutgoingElements(channel);
|
||||
if (channel.current) {
|
||||
channel.current.volume = channel.targetVolume;
|
||||
}
|
||||
channel.transition.active = false;
|
||||
channel.transition.completedRevision = transitionRevision;
|
||||
}
|
||||
|
||||
private cancelLoopTransitionTimer(channel: LoopChannel) {
|
||||
if (channel.fadeTimer !== undefined) {
|
||||
window.clearInterval(channel.fadeTimer);
|
||||
channel.fadeTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private retireOutgoingElements(channel: LoopChannel) {
|
||||
channel.outgoing.forEach((element) => this.retireLoopElement(element));
|
||||
channel.outgoing.clear();
|
||||
channel.outgoingStartVolumes.clear();
|
||||
}
|
||||
|
||||
private retireLoopElement(element: HTMLAudioElement) {
|
||||
element.pause();
|
||||
element.src = '';
|
||||
}
|
||||
|
||||
private loopElements() {
|
||||
const elements = new Set<HTMLAudioElement>();
|
||||
for (const channel of [this.musicChannel, this.ambienceChannel]) {
|
||||
if (channel.current) {
|
||||
elements.add(channel.current);
|
||||
}
|
||||
channel.outgoing.forEach((element) => elements.add(element));
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
private loopChannelDebugState(channel: LoopChannel) {
|
||||
return {
|
||||
currentKey: channel.currentKey ?? null,
|
||||
pendingKey: channel.hasPendingRequest ? channel.pendingKey ?? null : null,
|
||||
hasPendingRequest: channel.hasPendingRequest,
|
||||
elementRevision: channel.elementRevision,
|
||||
activeElementCount: (channel.current ? 1 : 0) + channel.outgoing.size,
|
||||
targetVolume: channel.targetVolume,
|
||||
transition: {
|
||||
revision: channel.transition.revision,
|
||||
completedRevision: channel.transition.completedRevision,
|
||||
active: channel.transition.active,
|
||||
last: channel.transition.last ? { ...channel.transition.last } : null
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
Reference in New Issue
Block a user