feat: add layered soundscape channels

This commit is contained in:
2026-07-13 05:00:06 +09:00
parent ed76448138
commit 1eedd63163
2 changed files with 655 additions and 64 deletions

View File

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