/** * AudioManager Module * Manages loading and playback of non-TTS audio effects triggered by tags. */ import { BaseModule } from './base-module.js'; 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.musicFadeToken = 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']; } /** * Load module dependencies * @returns {Promise} - Resolves when dependencies are loaded */ async loadDependencies() { try { this.reportProgress(40, "Initializing audio system"); return true; } catch (error) { console.error("Error loading AudioManager dependencies:", error); return false; } } /** * Initialize the module * @returns {Promise} - Resolves with success status */ async initialize() { try { // Set up audio context if needed this.setupAudioContext(); this.loadPersistedVolumes(); this.setupEventListeners(); // Load some basic sound effects this.reportProgress(80, "Loading sound effects"); this.reportProgress(100, "Audio system ready"); return true; } catch (error) { console.error("Error initializing AudioManager:", error); 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, 'story:tag', (event) => { this.handleStoryTag(event.detail || {}); }); this.addEventListener(document, 'game:config', (event) => { this.applyGameConfig(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:audio-started', () => { 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(); }); this.addEventListener(document, 'tts:speechCompleted', () => { this.activeTtsPlaybackCount = Math.max(0, this.activeTtsPlaybackCount - 1); this.restoreMusicIfSpeechFinished(); }); const unlock = () => this.unlockPendingAudio(); document.addEventListener('pointerdown', unlock, { passive: true }); document.addEventListener('keydown', unlock); } applyGameConfig(config) { const assets = config?.assets || {}; this.assetRoots = { images: assets.images || this.assetRoots.images, music: assets.music || this.assetRoots.music, sounds: assets.sounds || assets.sfx || this.assetRoots.sounds }; } /** * Set up Web Audio API context if needed */ setupAudioContext() { // Only create if needed for advanced audio features if (typeof AudioContext !== 'undefined' || typeof webkitAudioContext !== 'undefined') { const AudioContextClass = window.AudioContext || window.webkitAudioContext; this.audioContext = new AudioContextClass(); } } /** * Load a sound file * @param {string} id - The identifier for the sound * @param {string} url - The URL of the sound file * @returns {Promise} A promise that resolves when the sound is loaded */ 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); }, { once: true }); audio.addEventListener('error', (error) => { reject(error); }); audio.load(); }); } /** * Play a sound * @param {string} id - The identifier for the sound * @param {boolean} loop - Whether to loop the sound * @returns {HTMLAudioElement|null} The audio element or null if not found */ playSound(id, loop = false) { const audio = this.sounds.get(id); if (!audio) { console.warn(`Sound with id "${id}" not found.`); return null; } if (loop) { if (this.currentLoop) { this.currentLoop.pause(); this.currentLoop.currentTime = 0; } audio.loop = true; this.currentLoop = audio; } else { 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); }); return audio; } /** * Play a sound from a URL directly (without preloading) * @param {string} url - The URL of the sound file * @param {boolean} loop - Whether to loop the sound * @returns {HTMLAudioElement} The audio element */ playSoundFromUrl(url, loop = false) { if (loop) { if (this.currentLoop) { this.currentLoop.pause(); this.currentLoop.removeAttribute('src'); this.currentLoop.load(); } 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 { this.currentAudio = new Audio(url); this.currentAudio.volume = this.getSfxVolume(); this.currentAudio.play().catch(error => { console.error('Error playing audio:', error); }); return this.currentAudio; } } /** * Stop a specific sound * @param {string} id - The identifier for the sound */ stopSound(id) { const audio = this.sounds.get(id); if (audio) { audio.pause(); audio.currentTime = 0; } } /** * Stop all sounds */ stopAllSounds() { if (this.currentAudio) { this.currentAudio.pause(); this.currentAudio.currentTime = 0; this.currentAudio = null; } if (this.currentLoop) { this.currentLoop.pause(); this.currentLoop.currentTime = 0; this.currentLoop = null; } this.sounds.forEach(audio => { audio.pause(); audio.currentTime = 0; }); this.stopCurrentMusic(); this.queuedMusic = null; this.pendingMusicPlayback = null; this.activeTtsPlaybackCount = 0; this.ttsQueueEmpty = true; this.musicDuckingFactor = 1.0; this.musicFadeToken += 1; } /** * Set the master volume for all sounds * @param {number} volume - The volume level (0.0 to 1.0) */ setMasterVolume(volume) { this.masterVolume = this.clampVolume(volume); this.updateVolumes(); } /** * Set the speech volume * @param {number} volume - The volume level (0.0 to 1.0) */ setTtsVolume(volume) { this.ttsVolume = this.clampVolume(volume); } /** * Set the music volume * @param {number} volume - The volume level (0.0 to 1.0) */ setMusicVolume(volume) { this.musicVolume = this.clampVolume(volume); this.updateVolumes(); } /** * Set the sound effects volume * @param {number} volume - The volume level (0.0 to 1.0) */ setSfxVolume(volume) { this.sfxVolume = this.clampVolume(volume); this.updateVolumes(); } /** * Update all volume levels based on current settings */ updateVolumes() { this.sounds.forEach(audio => { const isMusic = audio.loop; audio.volume = this.masterVolume * (isMusic ? this.musicVolume : this.sfxVolume); }); if (this.currentAudio) { this.currentAudio.volume = this.masterVolume * this.sfxVolume; } if (this.currentLoop) { this.currentLoop.volume = this.getMusicVolume(); } 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.3, 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 token = ++this.musicFadeToken; const startVolume = audio.volume; const targetVolume = this.getUnduckedMusicVolume() * this.musicDuckingFactor; const start = performance.now(); const tick = () => { if (token !== this.musicFadeToken || this.currentMusic !== audio) { return; } 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, cue); } 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 }); } handleStoryTag(tag) { const key = String(tag?.key || '').toLowerCase(); const filename = String(tag?.value || tag?.filename || '').trim(); if (!key || !filename) { return; } if (key === 'sfx' || key === 'sound' || key === 'audio') { this.playSfx(filename, this.parseSfxTagOptions(tag.param || tag.options || '')); return; } if (key === 'music') { const options = this.parseMusicTagOptions(tag.param || tag.options || ''); this.playMusic(filename, options.mode, { loop: options.loop }); } } parseMusicTagOptions(optionText) { const options = { mode: 'crossfade', loop: true }; String(optionText || '') .split(/[,\s]+/) .map(token => token.trim().toLowerCase()) .filter(Boolean) .forEach(token => { const [key, value] = token.split('='); if (['queue', 'crossfade', 'cut'].includes(token)) { options.mode = token; } else if (['loop', 'looped', 'repeat'].includes(token)) { options.loop = true; } else if (['once', 'single', 'no-loop', 'noloop'].includes(token)) { options.loop = false; } else if (key === 'loop') { options.loop = !['false', '0', 'no', 'once'].includes(value); } else if (key === 'mode' && ['queue', 'crossfade', 'cut'].includes(value)) { options.mode = value; } }); return options; } parseSfxTagOptions(optionText) { const options = { maxDurationSeconds: 0, endMode: 'stop', fadeDurationSeconds: 2 }; String(optionText || '') .split(/[,\s]+/) .map(token => token.trim().toLowerCase()) .filter(Boolean) .forEach(token => { const [key, value] = token.split('='); if (['fade', 'fadeout', 'fade-out'].includes(token)) { options.endMode = 'fade'; } else if (['stop', 'cut', 'halt'].includes(token)) { options.endMode = 'stop'; } else if (['max', 'duration', 'max-duration', 'limit', 'stop-after', 'fade-after'].includes(key)) { const seconds = Number(value); options.maxDurationSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0; if (key === 'fade-after') options.endMode = 'fade'; } else if (/^\d+(\.\d+)?s?$/.test(token)) { options.maxDurationSeconds = Number(token.replace(/s$/, '')); } else if (key === 'mode' && ['fade', 'fadeout', 'fade-out', 'stop', 'cut'].includes(value)) { options.endMode = value.startsWith('fade') ? 'fade' : 'stop'; } else if (['fade-duration', 'fade-time', 'fade'].includes(key)) { const seconds = Number(value); if (Number.isFinite(seconds)) { options.fadeDurationSeconds = Math.max(0.1, seconds); options.endMode = 'fade'; } } }); return options; } async playSfx(filename, options = {}) { try { const template = await this.preloadSfx(filename); const audio = template.cloneNode(true); audio.volume = this.getSfxVolume(); this.currentAudio = audio; const maxDuration = Math.max(0, Number(options.maxDurationSeconds || options.maxDuration || 0)) * 1000; const endMode = String(options.endMode || options.mode || 'stop').toLowerCase().startsWith('fade') ? 'fade' : 'stop'; const fadeDuration = Math.max(100, Number(options.fadeDurationSeconds || options.fadeDuration || 2) * 1000); let maxTimer = null; audio.addEventListener('ended', () => { if (maxTimer) clearTimeout(maxTimer); if (this.currentAudio === audio) { this.currentAudio = null; } }, { once: true }); await audio.play(); if (maxDuration > 0) { const timeoutDuration = endMode === 'fade' ? Math.max(0, maxDuration - fadeDuration) : maxDuration; maxTimer = setTimeout(() => { if (audio.paused || audio.ended) return; if (endMode === 'fade') { console.log(`AudioManager: Fading sound effect ${filename} over ${fadeDuration}ms`); this.fadeOutAudio(audio, fadeDuration); } else { audio.pause(); audio.currentTime = 0; if (this.currentAudio === audio) this.currentAudio = null; } }, timeoutDuration); } console.log(`AudioManager: Playing sound effect ${filename}`); return audio; } catch (error) { console.error('AudioManager: Failed to play sound effect:', error); return null; } } fadeOutAudio(audio, duration = 1000) { if (!audio) return Promise.resolve(false); const startVolume = audio.volume; const startedAt = performance.now(); return new Promise(resolve => { const step = () => { const progress = Math.min(1, (performance.now() - startedAt) / duration); audio.volume = startVolume * (1 - progress); if (progress < 1 && !audio.paused && !audio.ended) { requestAnimationFrame(step); return; } audio.pause(); audio.currentTime = 0; if (this.currentAudio === audio) this.currentAudio = null; resolve(true); }; requestAnimationFrame(step); }); } 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(); } /** * Check if a sound is currently playing * @param {string} id - The identifier for the sound * @returns {boolean} Whether the sound is playing */ isPlaying(id) { const audio = this.sounds.get(id); return audio ? !audio.paused : false; } /** * Fade out the current audio * @param {number} duration - The duration of the fade in milliseconds * @returns {Promise} A promise that resolves when the fade is complete */ fadeOutCurrentAudio(duration = 1000) { return new Promise((resolve) => { if (!this.currentAudio || this.currentAudio.paused) { resolve(); return; } const audio = this.currentAudio; const initialVolume = audio.volume; const volumeStep = initialVolume / (duration / 50); let currentVolume = initialVolume; const fadeInterval = setInterval(() => { currentVolume -= volumeStep; if (currentVolume <= 0) { clearInterval(fadeInterval); audio.pause(); audio.currentTime = 0; audio.volume = initialVolume; // Reset volume for future use resolve(); } else { audio.volume = currentVolume; } }, 50); }); } /** * Play speech audio from a Blob * @param {Blob} audioBlob - The audio Blob to play * @param {Object} options - Playback options * @returns {Promise} - Resolves with success status */ async playSpeech(audioBlob, options = {}) { if (!audioBlob || !(audioBlob instanceof Blob)) { console.error('AudioManager: Invalid speech audio blob provided'); return false; } try { // Create object URL from Blob const audioUrl = URL.createObjectURL(audioBlob); // Stop any current non-looping audio if (this.currentAudio) { this.currentAudio.pause(); this.currentAudio.currentTime = 0; } // Create new audio element const audio = new Audio(audioUrl); // Get speech volume from options or preferences const persistenceManager = this.getModule('persistence-manager'); let speechVolume = 1.0; if (persistenceManager) { // Get speech volume from preferences (0.0 to 1.0) speechVolume = persistenceManager.getPreference('tts', 'volume') || 1.0; } // Override with options if provided if (options.volume !== undefined) { speechVolume = options.volume; } // Apply master volume and speech volume audio.volume = this.masterVolume * speechVolume * this._ttsVolume; // Set up cleanup audio.onended = () => { URL.revokeObjectURL(audioUrl); if (this.currentAudio === audio) { this.currentAudio = null; } if (options.onComplete && typeof options.onComplete === 'function') { options.onComplete(); } // Dispatch event for speech completion window.dispatchEvent(new CustomEvent('tts:speak-completed')); }; // Handle errors audio.onerror = (error) => { console.error('AudioManager: Error playing speech audio:', error); URL.revokeObjectURL(audioUrl); if (this.currentAudio === audio) { this.currentAudio = null; } if (options.onError && typeof options.onError === 'function') { options.onError(error); } // Dispatch event for speech error window.dispatchEvent(new CustomEvent('tts:speak-error', { detail: { error: error } })); }; // Store as current audio this.currentAudio = audio; // Play the audio await audio.play(); return true; } catch (error) { console.error('AudioManager: Error setting up speech audio:', error); // Dispatch event for speech error window.dispatchEvent(new CustomEvent('tts:speak-error', { detail: { error: error } })); return false; } } } // Create the singleton instance const AudioManager = new AudioManagerModule(); // Export the module export { AudioManager };