Checkpoint current interactive fiction state
This commit is contained in:
@@ -8,12 +8,24 @@ class AudioManagerModule extends BaseModule {
|
||||
constructor() {
|
||||
super('audio-manager', 'Audio Manager');
|
||||
this.sounds = new Map();
|
||||
this.sfxCache = new Map();
|
||||
this.currentAudio = null;
|
||||
this.currentLoop = null;
|
||||
this.currentMusic = null;
|
||||
this.queuedMusic = null;
|
||||
this.masterVolume = 1.0;
|
||||
this.musicVolume = 1.0;
|
||||
this.sfxVolume = 1.0;
|
||||
this.ttsVolume = 1.0;
|
||||
this.musicDuckingFactor = 1.0;
|
||||
this.activeTtsPlaybackCount = 0;
|
||||
this.ttsQueueEmpty = true;
|
||||
this.pendingMusicPlayback = null;
|
||||
this.assetRoots = {
|
||||
images: '/images/',
|
||||
music: '/music/',
|
||||
sounds: '/sounds/'
|
||||
};
|
||||
|
||||
// Add persistence-manager as a dependency
|
||||
this.dependencies = ['persistence-manager'];
|
||||
@@ -41,6 +53,8 @@ class AudioManagerModule extends BaseModule {
|
||||
try {
|
||||
// Set up audio context if needed
|
||||
this.setupAudioContext();
|
||||
this.loadPersistedVolumes();
|
||||
this.setupEventListeners();
|
||||
|
||||
// Load some basic sound effects
|
||||
this.reportProgress(80, "Loading sound effects");
|
||||
@@ -52,6 +66,60 @@ class AudioManagerModule extends BaseModule {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
loadPersistedVolumes() {
|
||||
const persistenceManager = this.getModule('persistence-manager');
|
||||
if (!persistenceManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.masterVolume = this.clampVolume(persistenceManager.getPreference('audio', 'masterVolume', this.masterVolume));
|
||||
this.musicVolume = this.clampVolume(persistenceManager.getPreference('audio', 'musicVolume', this.musicVolume));
|
||||
this.sfxVolume = this.clampVolume(persistenceManager.getPreference('audio', 'sfxVolume', this.sfxVolume));
|
||||
this.ttsVolume = this.clampVolume(persistenceManager.getPreference('audio', 'ttsVolume', this.ttsVolume));
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
this.addEventListener(document, 'story:media-cue', (event) => {
|
||||
this.handleMediaCue(event.detail || {});
|
||||
});
|
||||
|
||||
this.addEventListener(document, 'story:media-block', (event) => {
|
||||
this.handleMediaBlock(event.detail || {});
|
||||
});
|
||||
|
||||
this.addEventListener(document, 'preference-updated', (event) => {
|
||||
const { category, key, value } = event.detail || {};
|
||||
if (category !== 'audio') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'masterVolume') this.setMasterVolume(value);
|
||||
if (key === 'musicVolume') this.setMusicVolume(value);
|
||||
if (key === 'sfxVolume') this.setSfxVolume(value);
|
||||
if (key === 'ttsVolume') this.setTtsVolume(value);
|
||||
});
|
||||
|
||||
this.addEventListener(document, 'tts:playback-start', () => {
|
||||
this.activeTtsPlaybackCount += 1;
|
||||
this.ttsQueueEmpty = false;
|
||||
this.duckMusicForSpeech();
|
||||
});
|
||||
|
||||
this.addEventListener(document, 'tts:playback-end', () => {
|
||||
this.activeTtsPlaybackCount = Math.max(0, this.activeTtsPlaybackCount - 1);
|
||||
this.restoreMusicIfSpeechFinished();
|
||||
});
|
||||
|
||||
this.addEventListener(document, 'tts:queue-empty', () => {
|
||||
this.ttsQueueEmpty = true;
|
||||
this.restoreMusicIfSpeechFinished();
|
||||
});
|
||||
|
||||
const unlock = () => this.unlockPendingAudio();
|
||||
document.addEventListener('pointerdown', unlock, { passive: true });
|
||||
document.addEventListener('keydown', unlock);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up Web Audio API context if needed
|
||||
@@ -73,6 +141,7 @@ class AudioManagerModule extends BaseModule {
|
||||
loadSound(id, url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const audio = new Audio(url);
|
||||
audio.preload = 'auto';
|
||||
audio.addEventListener('canplaythrough', () => {
|
||||
this.sounds.set(id, audio);
|
||||
resolve(audio);
|
||||
@@ -105,13 +174,15 @@ class AudioManagerModule extends BaseModule {
|
||||
audio.loop = true;
|
||||
this.currentLoop = audio;
|
||||
} else {
|
||||
if (this.currentAudio) {
|
||||
this.currentAudio.pause();
|
||||
this.currentAudio.currentTime = 0;
|
||||
}
|
||||
this.currentAudio = audio;
|
||||
this.currentAudio = audio.cloneNode(true);
|
||||
this.currentAudio.volume = this.getSfxVolume();
|
||||
this.currentAudio.play().catch(error => {
|
||||
console.error('Error playing audio:', error);
|
||||
});
|
||||
return this.currentAudio;
|
||||
}
|
||||
|
||||
audio.volume = this.getMusicVolume();
|
||||
audio.play().catch(error => {
|
||||
console.error('Error playing audio:', error);
|
||||
});
|
||||
@@ -134,17 +205,14 @@ class AudioManagerModule extends BaseModule {
|
||||
}
|
||||
this.currentLoop = new Audio(url);
|
||||
this.currentLoop.loop = true;
|
||||
this.currentLoop.volume = this.getMusicVolume();
|
||||
this.currentLoop.play().catch(error => {
|
||||
console.error('Error playing audio loop:', error);
|
||||
});
|
||||
return this.currentLoop;
|
||||
} else {
|
||||
if (this.currentAudio) {
|
||||
this.currentAudio.pause();
|
||||
this.currentAudio.removeAttribute('src');
|
||||
this.currentAudio.load();
|
||||
}
|
||||
this.currentAudio = new Audio(url);
|
||||
this.currentAudio.volume = this.getSfxVolume();
|
||||
this.currentAudio.play().catch(error => {
|
||||
console.error('Error playing audio:', error);
|
||||
});
|
||||
@@ -191,7 +259,7 @@ class AudioManagerModule extends BaseModule {
|
||||
* @param {number} volume - The volume level (0.0 to 1.0)
|
||||
*/
|
||||
setMasterVolume(volume) {
|
||||
this.masterVolume = Math.max(0, Math.min(1, volume));
|
||||
this.masterVolume = this.clampVolume(volume);
|
||||
this.updateVolumes();
|
||||
}
|
||||
|
||||
@@ -200,11 +268,7 @@ class AudioManagerModule extends BaseModule {
|
||||
* @param {number} volume - The volume level (0.0 to 1.0)
|
||||
*/
|
||||
setTtsVolume(volume) {
|
||||
this.ttsVolume = Math.max(0, Math.min(1, volume));
|
||||
// Apply to current non-loop audio if it exists
|
||||
if (this.currentAudio) {
|
||||
this.currentAudio.volume = this.masterVolume * this.ttsVolume;
|
||||
}
|
||||
this.ttsVolume = this.clampVolume(volume);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,11 +276,8 @@ class AudioManagerModule extends BaseModule {
|
||||
* @param {number} volume - The volume level (0.0 to 1.0)
|
||||
*/
|
||||
setMusicVolume(volume) {
|
||||
this.musicVolume = Math.max(0, Math.min(1, volume));
|
||||
// Apply to current loop if it exists
|
||||
if (this.currentLoop) {
|
||||
this.currentLoop.volume = this.masterVolume * this.musicVolume;
|
||||
}
|
||||
this.musicVolume = this.clampVolume(volume);
|
||||
this.updateVolumes();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,11 +285,8 @@ class AudioManagerModule extends BaseModule {
|
||||
* @param {number} volume - The volume level (0.0 to 1.0)
|
||||
*/
|
||||
setSfxVolume(volume) {
|
||||
this.sfxVolume = Math.max(0, Math.min(1, volume));
|
||||
// Apply to current non-loop audio if it exists
|
||||
if (this.currentAudio) {
|
||||
this.currentAudio.volume = this.masterVolume * this.sfxVolume;
|
||||
}
|
||||
this.sfxVolume = this.clampVolume(volume);
|
||||
this.updateVolumes();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,6 +305,258 @@ class AudioManagerModule extends BaseModule {
|
||||
if (this.currentLoop) {
|
||||
this.currentLoop.volume = this.masterVolume * this.musicVolume;
|
||||
}
|
||||
|
||||
if (this.currentMusic) {
|
||||
this.currentMusic.volume = this.getMusicVolume();
|
||||
}
|
||||
}
|
||||
|
||||
clampVolume(volume) {
|
||||
return Math.max(0, Math.min(1, Number.isFinite(Number(volume)) ? Number(volume) : 1));
|
||||
}
|
||||
|
||||
getSfxVolume() {
|
||||
return this.masterVolume * this.sfxVolume;
|
||||
}
|
||||
|
||||
getMusicVolume() {
|
||||
return this.masterVolume * this.musicVolume * this.musicDuckingFactor;
|
||||
}
|
||||
|
||||
getUnduckedMusicVolume() {
|
||||
return this.masterVolume * this.musicVolume;
|
||||
}
|
||||
|
||||
duckMusicForSpeech() {
|
||||
console.log('AudioManager: Ducking music for TTS playback');
|
||||
this.fadeMusicTo(0.7, 500);
|
||||
}
|
||||
|
||||
restoreMusicAfterSpeech() {
|
||||
console.log('AudioManager: Restoring music after TTS queue drained');
|
||||
this.fadeMusicTo(1.0, 900);
|
||||
}
|
||||
|
||||
restoreMusicIfSpeechFinished() {
|
||||
if (this.activeTtsPlaybackCount === 0 && this.ttsQueueEmpty) {
|
||||
this.restoreMusicAfterSpeech();
|
||||
}
|
||||
}
|
||||
|
||||
fadeMusicTo(factor, duration = 700) {
|
||||
this.musicDuckingFactor = Math.max(0, Math.min(1, factor));
|
||||
if (!this.currentMusic) {
|
||||
return;
|
||||
}
|
||||
|
||||
const audio = this.currentMusic;
|
||||
const startVolume = audio.volume;
|
||||
const targetVolume = this.getUnduckedMusicVolume() * this.musicDuckingFactor;
|
||||
const start = performance.now();
|
||||
|
||||
const tick = () => {
|
||||
const progress = Math.min(1, (performance.now() - start) / duration);
|
||||
audio.volume = startVolume + ((targetVolume - startVolume) * progress);
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
|
||||
tick();
|
||||
}
|
||||
|
||||
getAssetUrl(kind, filename) {
|
||||
const root = this.assetRoots[kind];
|
||||
if (!root) {
|
||||
throw new Error(`Unknown audio asset kind: ${kind}`);
|
||||
}
|
||||
|
||||
const safeName = String(filename || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
if (!safeName || safeName.includes('..') || /^[a-z]+:/i.test(safeName)) {
|
||||
throw new Error(`Unsafe asset filename: ${filename}`);
|
||||
}
|
||||
|
||||
return root + safeName.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
async preloadSfx(filename) {
|
||||
const url = this.getAssetUrl('sounds', filename);
|
||||
if (this.sfxCache.has(url)) {
|
||||
return this.sfxCache.get(url);
|
||||
}
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const audio = new Audio(url);
|
||||
audio.preload = 'auto';
|
||||
audio.volume = this.getSfxVolume();
|
||||
audio.addEventListener('canplaythrough', () => resolve(audio), { once: true });
|
||||
audio.addEventListener('error', () => reject(new Error(`Failed to preload sound effect: ${url}`)), { once: true });
|
||||
audio.load();
|
||||
});
|
||||
|
||||
this.sfxCache.set(url, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async preloadMediaCues(cues = []) {
|
||||
const tasks = cues
|
||||
.filter(cue => cue && cue.type === 'sfx' && cue.filename)
|
||||
.map(cue => this.preloadSfx(cue.filename).catch(error => {
|
||||
console.warn('AudioManager: SFX preload failed:', error);
|
||||
return null;
|
||||
}));
|
||||
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
|
||||
handleMediaCue(cue) {
|
||||
if (!cue || !cue.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cue.type === 'sfx') {
|
||||
this.playSfx(cue.filename);
|
||||
} else if (cue.type === 'music') {
|
||||
this.playMusic(cue.filename, cue.mode || 'crossfade', { loop: cue.loop !== false });
|
||||
}
|
||||
}
|
||||
|
||||
handleMediaBlock(block) {
|
||||
if (!block || block.type !== 'music') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.playMusic(block.filename, block.mode || 'crossfade', { loop: block.loop !== false });
|
||||
}
|
||||
|
||||
async playSfx(filename) {
|
||||
try {
|
||||
const template = await this.preloadSfx(filename);
|
||||
const audio = template.cloneNode(true);
|
||||
audio.volume = this.getSfxVolume();
|
||||
this.currentAudio = audio;
|
||||
audio.addEventListener('ended', () => {
|
||||
if (this.currentAudio === audio) {
|
||||
this.currentAudio = null;
|
||||
}
|
||||
}, { once: true });
|
||||
await audio.play();
|
||||
console.log(`AudioManager: Playing sound effect ${filename}`);
|
||||
return audio;
|
||||
} catch (error) {
|
||||
console.error('AudioManager: Failed to play sound effect:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async playMusic(filename, mode = 'crossfade', options = {}) {
|
||||
const url = this.getAssetUrl('music', filename);
|
||||
const shouldLoop = options.loop !== false;
|
||||
|
||||
if (mode === 'queue' && this.currentMusic && !this.currentMusic.paused) {
|
||||
this.queuedMusic = { filename, mode: 'cut', options: { loop: shouldLoop } };
|
||||
this.currentMusic.addEventListener('ended', () => {
|
||||
const queued = this.queuedMusic;
|
||||
this.queuedMusic = null;
|
||||
if (queued) this.playMusic(queued.filename, queued.mode, queued.options);
|
||||
}, { once: true });
|
||||
console.log(`AudioManager: Queued music ${filename}`);
|
||||
return this.currentMusic;
|
||||
}
|
||||
|
||||
const next = new Audio(url);
|
||||
next.loop = shouldLoop;
|
||||
next.volume = mode === 'crossfade' && this.currentMusic ? 0 : this.getMusicVolume();
|
||||
next.addEventListener('ended', () => {
|
||||
if (this.currentMusic === next) {
|
||||
this.currentMusic = null;
|
||||
}
|
||||
});
|
||||
|
||||
if (mode === 'cut' || !this.currentMusic) {
|
||||
this.stopCurrentMusic();
|
||||
this.currentMusic = next;
|
||||
await this.startMusicAudio(next, filename);
|
||||
return next;
|
||||
}
|
||||
|
||||
const previous = this.currentMusic;
|
||||
this.currentMusic = next;
|
||||
await this.startMusicAudio(next, filename);
|
||||
this.crossfade(previous, next, 1500);
|
||||
console.log(`AudioManager: Crossfading music to ${filename}`);
|
||||
return next;
|
||||
}
|
||||
|
||||
async startMusicAudio(audio, filename) {
|
||||
try {
|
||||
await audio.play();
|
||||
console.log(`AudioManager: Playing music ${filename}`);
|
||||
} catch (error) {
|
||||
this.pendingMusicPlayback = { audio, filename };
|
||||
console.warn('AudioManager: Music playback is waiting for user interaction:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async unlockPendingAudio() {
|
||||
if (this.audioContext && this.audioContext.state === 'suspended') {
|
||||
try {
|
||||
await this.audioContext.resume();
|
||||
} catch (error) {
|
||||
console.warn('AudioManager: Failed to resume audio context:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.pendingMusicPlayback) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = this.pendingMusicPlayback;
|
||||
this.pendingMusicPlayback = null;
|
||||
pending.audio.volume = this.getMusicVolume();
|
||||
|
||||
try {
|
||||
await pending.audio.play();
|
||||
console.log(`AudioManager: Resumed pending music ${pending.filename}`);
|
||||
} catch (error) {
|
||||
this.pendingMusicPlayback = pending;
|
||||
console.warn('AudioManager: Pending music still blocked:', error);
|
||||
}
|
||||
}
|
||||
|
||||
stopCurrentMusic() {
|
||||
if (!this.currentMusic) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentMusic.pause();
|
||||
this.currentMusic.currentTime = 0;
|
||||
this.currentMusic = null;
|
||||
}
|
||||
|
||||
crossfade(previous, next, duration = 1500) {
|
||||
const start = performance.now();
|
||||
const previousStart = previous ? previous.volume : 0;
|
||||
const target = this.getMusicVolume();
|
||||
|
||||
const tick = () => {
|
||||
const progress = Math.min(1, (performance.now() - start) / duration);
|
||||
if (previous) previous.volume = previousStart * (1 - progress);
|
||||
next.volume = target * progress;
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
|
||||
if (previous) {
|
||||
previous.pause();
|
||||
previous.currentTime = 0;
|
||||
}
|
||||
next.volume = this.getMusicVolume();
|
||||
};
|
||||
|
||||
tick();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user