/** * 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.musicCache = new Map(); this.imageCache = new Map(); this.currentAudio = null; this.currentAudioRole = 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.masterVolumeEnabled = true; this.musicVolumeEnabled = true; this.sfxVolumeEnabled = true; this.ttsVolumeEnabled = true; this.musicDuckingAmount = 0.3; this.musicDuckingEnabled = true; this.musicDuckingFactor = 1.0; this.musicFadeToken = 0; this.activeTtsPlaybackCount = 0; this.ttsQueueEmpty = true; this.pendingMusicPlayback = null; this.currentMusicState = null; this.mediaPreloadTimeoutMs = 60000; 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)); this.masterVolumeEnabled = persistenceManager.getPreference('audio', 'masterVolumeEnabled', this.masterVolumeEnabled) !== false; this.musicVolumeEnabled = persistenceManager.getPreference('audio', 'musicVolumeEnabled', this.musicVolumeEnabled) !== false; this.sfxVolumeEnabled = persistenceManager.getPreference('audio', 'sfxVolumeEnabled', this.sfxVolumeEnabled) !== false; this.ttsVolumeEnabled = persistenceManager.getPreference('audio', 'ttsVolumeEnabled', this.ttsVolumeEnabled) !== false; this.musicDuckingAmount = this.clampVolume(persistenceManager.getPreference('audio', 'musicDuckingAmount', this.musicDuckingAmount)); this.musicDuckingEnabled = persistenceManager.getPreference('audio', 'musicDuckingEnabled', this.musicDuckingEnabled) !== false; } 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); if (key === 'masterVolumeEnabled') this.setVolumeEnabled('master', value); if (key === 'musicVolumeEnabled') this.setVolumeEnabled('music', value); if (key === 'sfxVolumeEnabled') this.setVolumeEnabled('sfx', value); if (key === 'ttsVolumeEnabled') this.setVolumeEnabled('tts', value); if (key === 'musicDuckingAmount') this.setMusicDuckingAmount(value); if (key === 'musicDuckingEnabled') this.setMusicDuckingEnabled(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.currentAudioRole = 'sfx'; this.setMediaVolume(this.currentAudio, this.getSfxVolume()); this.currentAudio.play().catch(error => { console.error('Error playing audio:', error); }); return this.currentAudio; } this.setMediaVolume(audio, 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.setMediaVolume(this.currentLoop, this.getMusicVolume()); this.currentLoop.play().catch(error => { console.error('Error playing audio loop:', error); }); return this.currentLoop; } else { this.currentAudio = new Audio(url); this.currentAudioRole = 'sfx'; this.setMediaVolume(this.currentAudio, 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; this.currentAudioRole = 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); this.updateVolumes(); } /** * 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(); } setVolumeEnabled(kind, enabled) { const value = enabled !== false; if (kind === 'master') this.masterVolumeEnabled = value; if (kind === 'music') this.musicVolumeEnabled = value; if (kind === 'sfx') this.sfxVolumeEnabled = value; if (kind === 'tts') this.ttsVolumeEnabled = value; this.updateVolumes(); } setMusicDuckingAmount(amount) { this.musicDuckingAmount = this.clampVolume(amount); if (this.musicDuckingFactor !== 1.0) { this.duckMusicForSpeech(); } else { this.updateVolumes(); } } setMusicDuckingEnabled(enabled) { this.musicDuckingEnabled = enabled !== false; if (this.musicDuckingFactor !== 1.0) { this.duckMusicForSpeech(); } else { this.updateVolumes(); } } /** * Update all volume levels based on current settings */ updateVolumes() { this.sounds.forEach(audio => { const isMusic = audio.loop; this.setMediaVolume(audio, isMusic ? this.getMusicVolume() : this.getSfxVolume()); }); if (this.currentAudio) { this.setMediaVolume(this.currentAudio, this.currentAudioRole === 'tts' ? this.getTtsVolume() : this.getSfxVolume()); } if (this.currentLoop) { this.setMediaVolume(this.currentLoop, this.getMusicVolume()); } if (this.currentMusic) { this.setMediaVolume(this.currentMusic, this.getMusicVolume()); } } clampVolume(volume) { return Math.max(0, Math.min(1, Number.isFinite(Number(volume)) ? Number(volume) : 1)); } setMediaVolume(audio, volume) { if (!audio) return; audio.volume = this.clampVolume(volume); } getSfxVolume() { return this.getMasterVolume() * (this.sfxVolumeEnabled ? this.sfxVolume : 0); } getMusicVolume() { return this.getUnduckedMusicVolume() * this.musicDuckingFactor; } getUnduckedMusicVolume() { return this.getMasterVolume() * (this.musicVolumeEnabled ? this.musicVolume : 0); } getMasterVolume() { return this.masterVolumeEnabled ? this.masterVolume : 0; } getTtsVolume() { return this.getMasterVolume() * (this.ttsVolumeEnabled ? this.ttsVolume : 0); } duckMusicForSpeech() { console.log('AudioManager: Ducking music for TTS playback'); const factor = this.musicDuckingEnabled ? 1 - this.musicDuckingAmount : 1; this.fadeMusicTo(factor, 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 = this.clampVolume(audio.volume); const targetVolume = this.clampVolume(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); this.setMediaVolume(audio, 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 = this.preloadAudioUrl(url, 'sound effect') .then(audio => { this.setMediaVolume(audio, this.getSfxVolume()); return audio; }) .catch(error => { this.sfxCache.delete(url); throw error; }); this.sfxCache.set(url, promise); return promise; } async preloadMusic(filename) { const url = this.getAssetUrl('music', filename); if (this.musicCache.has(url)) return this.musicCache.get(url); const promise = this.preloadAudioUrl(url, 'music track') .then(audio => { this.setMediaVolume(audio, this.getMusicVolume()); return audio; }) .catch(error => { this.musicCache.delete(url); throw error; }); this.musicCache.set(url, promise); return promise; } preloadAudioUrl(url, label = 'audio') { return new Promise((resolve, reject) => { const audio = new Audio(url); let settled = false; const finish = (result, error = null) => { if (settled) return; settled = true; clearTimeout(timeoutId); audio.removeEventListener('canplaythrough', onReady); audio.removeEventListener('loadeddata', onReady); audio.removeEventListener('error', onError); if (error) { audio.pause(); audio.removeAttribute('src'); audio.load(); reject(error); } else { resolve(result); } }; const onReady = () => finish(audio); const onError = () => finish(null, new Error(`Failed to preload ${label}: ${url}`)); const timeoutId = setTimeout(() => { finish(null, new Error(`Timed out preloading ${label}: ${url}`)); }, this.mediaPreloadTimeoutMs); audio.preload = 'auto'; audio.addEventListener('canplaythrough', onReady, { once: true }); audio.addEventListener('loadeddata', onReady, { once: true }); audio.addEventListener('error', onError, { once: true }); audio.load(); }); } async preloadImage(filename) { const url = this.getAssetUrl('images', filename); if (this.imageCache.has(url)) return this.imageCache.get(url); const promise = new Promise((resolve, reject) => { const image = new Image(); let settled = false; const finish = (result, error = null) => { if (settled) return; settled = true; clearTimeout(timeoutId); image.onload = null; image.onerror = null; if (error) { image.src = ''; reject(error); } else { resolve(result); } }; image.decoding = 'async'; image.onload = () => { if (typeof image.decode === 'function') { image.decode().catch(() => null).then(() => finish(image)); } else { finish(image); } }; image.onerror = () => finish(null, new Error(`Failed to preload image: ${url}`)); const timeoutId = setTimeout(() => { finish(null, new Error(`Timed out preloading image: ${url}`)); }, this.mediaPreloadTimeoutMs); image.src = url; }).catch(error => { this.imageCache.delete(url); throw error; }); this.imageCache.set(url, promise); return promise; } async preloadStructuredBlock(block = {}) { const type = String(block.type || block.kind || '').toLowerCase(); const filename = block.filename || block.metadata?.filename; if (!filename) return null; if (type === 'image') return this.preloadImage(filename); if (type === 'music') return this.preloadMusic(filename); if (type === 'sfx' || type === 'sound') return this.preloadSfx(filename); return null; } async preloadMediaCues(cues = []) { const tasks = cues .filter(cue => cue && cue.filename) .map(cue => this.preloadStructuredBlock(cue).catch(error => { console.warn('AudioManager: Media cue preload failed:', error); throw error; })); return 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); this.setMediaVolume(audio, this.getSfxVolume()); this.currentAudio = audio; this.currentAudioRole = 'sfx'; 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; this.currentAudioRole = 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; if (this.currentAudio === null) this.currentAudioRole = 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 = this.clampVolume(audio.volume); const startedAt = performance.now(); return new Promise(resolve => { const step = () => { const progress = Math.min(1, (performance.now() - startedAt) / duration); this.setMediaVolume(audio, 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; if (this.currentAudio === null) this.currentAudioRole = null; resolve(true); }; requestAnimationFrame(step); }); } async playMusic(filename, mode = 'crossfade', options = {}) { const url = this.getAssetUrl('music', filename); const shouldLoop = options.loop !== false; const startAt = Math.max(0, Number(options.startAt ?? options.currentTime ?? 0) || 0); const fadeInSeconds = Math.max(0, Number(options.fadeInSeconds ?? options.fadeIn ?? 0) || 0); 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; this.setMediaVolume(next, (mode === 'crossfade' && this.currentMusic) || fadeInSeconds > 0 ? 0 : this.getMusicVolume()); if (startAt > 0) { try { next.currentTime = startAt; } catch { next.addEventListener('loadedmetadata', () => { next.currentTime = Math.min(startAt, Number.isFinite(next.duration) ? next.duration : startAt); }, { once: true }); } } next.addEventListener('ended', () => { if (this.currentMusic === next) { this.currentMusic = null; this.currentMusicState = null; } }); const nextState = { filename, url, loop: shouldLoop, mode, startedAt: Date.now() }; if (mode === 'cut' || !this.currentMusic) { this.stopCurrentMusic(); this.currentMusic = next; this.currentMusicState = nextState; await this.startMusicAudio(next, filename); if (fadeInSeconds > 0) { this.fadeAudioTo(next, this.getMusicVolume(), fadeInSeconds * 1000); } return next; } const previous = this.currentMusic; this.currentMusic = next; this.currentMusicState = nextState; 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; this.setMediaVolume(pending.audio, 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; this.currentMusicState = null; } getMusicState() { if (!this.currentMusic || this.currentMusic.paused || this.currentMusic.ended || !this.currentMusicState?.filename) { return null; } return { filename: this.currentMusicState.filename, currentTime: Math.max(0, Number(this.currentMusic.currentTime || 0)), loop: Boolean(this.currentMusic.loop), mode: this.currentMusicState.mode || 'cut', volume: this.currentMusic.volume, duckingFactor: this.musicDuckingFactor }; } async restoreMusicState(state = null) { if (!state?.filename) return null; return this.playMusic(state.filename, 'cut', { loop: state.loop !== false, startAt: Number(state.currentTime || 0), fadeInSeconds: 1.5 }); } fadeAudioTo(audio, targetVolume, duration = 1000) { if (!audio) return Promise.resolve(false); const startVolume = this.clampVolume(audio.volume); const target = this.clampVolume(targetVolume); const startedAt = performance.now(); return new Promise(resolve => { const step = (now) => { const progress = duration <= 0 ? 1 : Math.min(1, (now - startedAt) / duration); this.setMediaVolume(audio, startVolume + ((target - startVolume) * progress)); if (progress < 1 && !audio.paused && !audio.ended) { requestAnimationFrame(step); return; } resolve(true); }; requestAnimationFrame(step); }); } crossfade(previous, next, duration = 1500) { const start = performance.now(); const previousStart = previous ? this.clampVolume(previous.volume) : 0; const target = this.clampVolume(this.getMusicVolume()); const tick = () => { const progress = Math.min(1, (performance.now() - start) / duration); if (previous) this.setMediaVolume(previous, previousStart * (1 - progress)); this.setMediaVolume(next, target * progress); if (progress < 1) { requestAnimationFrame(tick); return; } if (previous) { previous.pause(); previous.currentTime = 0; } this.setMediaVolume(next, 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 = this.clampVolume(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; this.setMediaVolume(audio, initialVolume); // Reset volume for future use if (this.currentAudio === audio) { this.currentAudio = null; this.currentAudioRole = null; } resolve(); } else { this.setMediaVolume(audio, 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; this.currentAudioRole = null; } // 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 this.setMediaVolume(audio, speechVolume * this.getTtsVolume()); // Set up cleanup audio.onended = () => { URL.revokeObjectURL(audioUrl); if (this.currentAudio === audio) { this.currentAudio = null; this.currentAudioRole = 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; this.currentAudioRole = 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; this.currentAudioRole = 'tts'; // 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 };