1110 lines
40 KiB
JavaScript
1110 lines
40 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.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<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));
|
|
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<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;
|
|
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 };
|