Files
ai.interactive.fiction/public/js/audio-manager-module.js
T
2026-05-14 23:18:30 +02:00

724 lines
23 KiB
JavaScript

/**
* 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<boolean>} - 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, '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);
}
/**
* 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);
} 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();
}
/**
* 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<boolean>} - 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 };