audio.js

/* jshint esversion: 10 */ 

// AudioManager singleton for all audio logic
/*jshint -W056 */ // Suppress "bad constructor" warning for ES6 class syntax (JSHint false positive)
/**
 * Singleton class for managing all game audio, including loading, playback, muting, and volume.
 *
 * Handles ambient, background, and effects channels with per-channel and global controls.
 * Provides methods for loading audio assets, playing sounds, and synchronising audio state with the UI.
 *
 * Properties:
 * - `audioContext`: The Web Audio API context.
 * - `buffers`: Stores decoded audio buffers for all sounds.
 * - `crystalSounds`, `celebrationSound`, `caveBackgroundSound`, `caveAmbienceSound`: Audio asset paths.
 * - `effectsGain`, `backgroundGain`, `ambientGain`: GainNodes for volume control.
 * - User volume and mute flags for each channel and globally.
 *
 * Usage:
 * Import and use the singleton `audioManager` for all audio operations in the game.
 *
 * @class
 */
class AudioManager {
    constructor() {
        this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
        this.buffers = {};
        this.crystalSounds = {
            blue: "assets/audio/blue-c-crystal.mp3",
            green: "assets/audio/green-d-crystal.mp3",
            pink: "assets/audio/pink-e-crystal.mp3",
            yellow: "assets/audio/yellow-f-crystal.mp3",
            orange: "assets/audio/orange-g-crystal.mp3",
        };
        this.celebrationSound = "assets/audio/celebration-chord.mp3";
        this.caveBackgroundSound = "assets/audio/cave-background-sounds.mp3";
        this.caveAmbienceSound = "assets/audio/cave-ambient-background-music.mp3";

        // Gain nodes
        this.effectsGain = this.audioContext.createGain();
        this.backgroundGain = this.audioContext.createGain();
        this.ambientGain = this.audioContext.createGain();

        // User volumes
        this._userAmbientVolume = 0.7;
        this._userBackgroundVolume = 0.7;
        this._userEffectsVolume = 0.7;

        // Mute flags
        this._isMuted = false;
        this._isAmbientMuted = false;
        this._isBackgroundMuted = false;
        this._isEffectsMuted = false;

        // Connect gain nodes
        this.effectsGain.connect(this.audioContext.destination);
        this.backgroundGain.connect(this.audioContext.destination);
        this.ambientGain.connect(this.audioContext.destination);
    }

    // --- Getters/Setters for flags and volumes ---
    get isMuted() { return this._isMuted; }
    set isMuted(val) {
        this._isMuted = val;
        this.updateMuteStates();
    }
    get isAmbientMuted() { return this._isAmbientMuted; }
    set isAmbientMuted(val) {
        this._isAmbientMuted = val;
        this.updateMuteStates();
    }
    get isBackgroundMuted() { return this._isBackgroundMuted; }
    set isBackgroundMuted(val) {
        this._isBackgroundMuted = val;
        this.updateMuteStates();
    }
    get isEffectsMuted() { return this._isEffectsMuted; }
    set isEffectsMuted(val) {
        this._isEffectsMuted = val;
        this.updateMuteStates();
    }
    get userAmbientVolume() { return this._userAmbientVolume; }
    set userAmbientVolume(val) {
        this._userAmbientVolume = val;
        this.updateMuteStates();
    }
    get userBackgroundVolume() { return this._userBackgroundVolume; }
    set userBackgroundVolume(val) {
        this._userBackgroundVolume = val;
        this.updateMuteStates();
    }
    get userEffectsVolume() { return this._userEffectsVolume; }
    set userEffectsVolume(val) {
        this._userEffectsVolume = val;
        this.updateMuteStates();
    }

    // -------------------- Main Audio Methods --------------------- //
     /**
     * Loads all audio files required for the game and stores them in the `buffers` object.
     *
     * This method fetches and decodes audio files for crystal sounds, background music,
     * ambient sound, and celebration effects.
     *
     * Behaviour:
     * - Iterates through the `crystalSounds` object to load crystal-specific audio files.
     * - Loads additional sounds for background, ambient, and celebration effects.
     * - Stores decoded audio data in the `buffers` property for playback.
     *
     * Notes:
     * - This method is asynchronous and should be called with `await`.
     * - The `buffers` object stores the decoded audio data for playback.
     *
     * References:
     * - [MDN Web Docs: Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)
     * - [MDN Web Docs: AudioBuffer](https://developer.mozilla.org/en-US/docs/Web/API/AudioBuffer)
     * @returns {Promise<void>}
     */
    async loadAllAudio() {
        // Load crystal sounds
        for (const [color, filePath] of Object.entries(this.crystalSounds)) {
            this.buffers[color] = await this.loadAudioFile(filePath);
        }
        this.buffers.celebration = await this.loadAudioFile(this.celebrationSound);
        this.buffers.background = await this.loadAudioFile(this.caveBackgroundSound);
        this.buffers.ambient = await this.loadAudioFile(this.caveAmbienceSound);
    }

    /**
     * Fetches an audio file from the given URL and decodes it into an AudioBuffer object.
     * 
     * This method uses the Fetch API to retrieve the audio file and decodes it using the Web Audio API.
     * If the fetch or decoding process fails, `null` is returned.
     * 
     * Behaviour:
     * - Fetches the audio file from the provided URL.
     * - Converts the response into an ArrayBuffer.
     * - Decodes the ArrayBuffer into an AudioBuffer using `audioContext.decodeAudioData`.
     * - Returns null if the fetch or decoding process fails.
     * 
     * Notes:
     * - This method is asynchronous and should be called with `await` or `.then`.
     * - The returned AudioBuffer can be used for audio playback in the game.
     * - The `try...catch` block ensures that any errors during the decoding process are caught and handled gracefully, preventing the application from crashing.
     * 
     * References:
     * - [MDN Web Docs: Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)
     * - [MDN Web Docs: ArrayBuffer](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer)
     * - [MDN Web Docs: AudioContext.decodeAudioData](https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/decodeAudioData)
     * - [W3Schools: Async/Await](https://www.w3schools.com/js/js_async.asp)
     * - [W3Schools: JavaScript Promises](https://www.w3schools.com/js/js_promise.asp)
     * - [FreeCodeCamp: Try/Catch in JavaScript – How to Handle Errors in JS](https://www.freecodecamp.org/news/try-catch-in-javascript/)
     * 
     * @param {string} url - The URL of the audio file to fetch and decode.
     * @returns {Promise<AudioBuffer|null>} A promise that resolves to the decoded AudioBuffer or `null` if an error occurs.
     * 
     * @example
     * // Inside the AudioManager class
     * const audioBuffer = await this.loadAudioFile("assets/audio/example.mp3");
     * if (audioBuffer) {
     *     console.log("Audio file loaded successfully.");
     * } else {
     *     console.error("Failed to load audio file.");
     * }
     */
    async loadAudioFile(url) {
        const response = await fetch(url);
        if (!response.ok) return null;
        const arrayBuffer = await response.arrayBuffer();
        try {
            return await this.audioContext.decodeAudioData(arrayBuffer);
        } catch {
            return null;
        }
    }

    /**
     * Plays a sound effect using the provided AudioBuffer.
     *
     * This method handles audio playback by creating a BufferSource, connecting it to the effects GainNode
     * for volume control, and starting playback. If the game is muted or no buffer is provided, playback is skipped.
     *
     * Behaviour:
     * - Checks if the game is muted (`isMuted` flag). If muted, skips playback.
     * - Validates the provided AudioBuffer. If no buffer is provided, exits early.
     * - Creates a BufferSource, connects it to the effects GainNode, and starts playback.
     *
     * Notes:
     * - The effects GainNode is used to control the volume of sound effects.
     * - This method is called whenever a crystal sound or celebration sound needs to be played.
     *
     * References:
     * - [MDN Web Docs: AudioContext.createBufferSource](https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/createBufferSource)
     * - [MDN Web Docs: AudioNode.connect](https://developer.mozilla.org/en-US/docs/Web/API/AudioNode/connect)
     * - [MDN Web Docs: AudioBufferSourceNode.start](https://developer.mozilla.org/en-US/docs/Web/API/AudioBufferSourceNode/start)
     *
     * @param {AudioBuffer} buffer - The AudioBuffer containing the sound to play.
     *
     * @example
     * // Play a crystal sound
     * this.playSound(this.buffers["blue"]);
     */
    playSound(buffer) {
        if (this.isMuted || !buffer) return;
        const source = this.audioContext.createBufferSource();
        source.buffer = buffer;
        source.connect(this.effectsGain);
        source.start(0);
    }

    /**
     * Plays the background sound for the game.
    *
    * This method handles playback of the background sound by:
    * - Creating a BufferSource for the background audio.
    * - Looping the background sound for continuous playback.
    * - Connecting the BufferSource to the persistent background GainNode for volume control.
    *
    * Behaviour:
    * - Checks if the background audio buffer is loaded. If not, exits early.
    * - Creates a BufferSource, sets it to loop, connects it to the background GainNode, and starts playback.
    *
    * Notes:
    * - The background GainNode is used to control the volume of the background sound.
    * - The background sound is looped to ensure continuous playback during the game.
    * - This method does not log or throw errors if the buffer is missing; it simply returns.
    *
    * References:
    * - [MDN Web Docs: AudioContext.createBufferSource](https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/createBufferSource)
    * - [MDN Web Docs: AudioNode.connect](https://developer.mozilla.org/en-US/docs/Web/API/AudioNode/connect)
    * - [MDN Web Docs: AudioBufferSourceNode.loop](https://developer.mozilla.org/en-US/docs/Web/API/AudioBufferSourceNode/loop)
    *
    * @example
    * // Play the background sound
    * this.playBackgroundSound();
    */
    playBackgroundSound() {
        if (!this.buffers.background) return;
        const source = this.audioContext.createBufferSource();
        source.buffer = this.buffers.background;
        source.loop = true;
        source.connect(this.backgroundGain);
        source.start(0);
    }

    /**
     * Plays the ambient soundscape for the game.
     *
     * This method handles playback of the ambient sound by:
     * - Creating a BufferSource for the ambient audio.
     * - Looping the ambient sound for continuous playback.
     * - Connecting the BufferSource to the persistent ambient GainNode for volume control.
     *
     * Behaviour:
     * - Checks if the ambient audio buffer is loaded. If not, exits early.
     * - Creates a BufferSource, sets it to loop, connects it to the ambient GainNode, and starts playback.
     *
     * Notes:
     * - The ambient GainNode is used to control the volume of the ambient sound.
     * - The ambient sound is looped to ensure continuous playback during the game.
     * - This method does not log or throw errors if the buffer is missing; it simply returns.
     *
     * References:
     * - [MDN Web Docs: AudioContext.createBufferSource](https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/createBufferSource)
     * - [MDN Web Docs: AudioNode.connect](https://developer.mozilla.org/en-US/docs/Web/API/AudioNode/connect)
     * - [MDN Web Docs: AudioBufferSourceNode.loop](https://developer.mozilla.org/en-US/docs/Web/API/AudioBufferSourceNode/loop)
     *
     * @example
     * // Play the ambient soundscape
     * this.playAmbientSound();
     */
    playAmbientSound() {
        if (!this.buffers.ambient) return;
        const source = this.audioContext.createBufferSource();
        source.buffer = this.buffers.ambient;
        source.loop = true;
        source.connect(this.ambientGain);
        source.start(0);
    }

    /**
     * Lowers the ambient sound volume gradually over a specified duration.
     *
     * This method reduces the volume of the ambient sound to 0.1 over 2 seconds using a linear ramp.
     * If the game is globally muted, the volume is immediately set to 0 and no ramping occurs.
     *
     * Behaviour:
     * - If the game is globally muted (`isMuted`) or ambient is muted (`isAmbientMuted`), sets the volume to 0 immediately.
     * - Otherwise, gradually lowers the volume to 0.1 over 2 seconds using `linearRampToValueAtTime`.
     *
     * Notes:
     * - The `linearRampToValueAtTime` method ensures a smooth transition in volume.
     * - This method is typically called during gameplay events that require reduced ambient sound (e.g., crystal sequence playback).
     *
     * References:
     * - [MDN Web Docs: AudioParam.linearRampToValueAtTime](https://developer.mozilla.org/en-US/docs/Web/API/AudioParam/linearRampToValueAtTime)
     *
     * @example
     * // Lower the ambient volume during a sequence
     * this.lowerAmbientVolume();
     */
    lowerAmbientVolume() {
        if (this.isMuted || this.isAmbientMuted) {
            this.ambientGain.gain.setValueAtTime(0, this.audioContext.currentTime);
            return;
        }
        this.ambientGain.gain.linearRampToValueAtTime(0.1, this.audioContext.currentTime + 2);
    }

    /**
     * Restores the ambient sound volume gradually over a specified duration.
     *
     * This method increases the volume of the ambient sound to the user's intended ambient volume (`userAmbientVolume`)
     * over 2 seconds using a linear ramp. If the game is globally muted (`isMuted`), the volume is immediately set to 0
     * and no ramping occurs.
     *
     * Behaviour:
     * - If the game is globally muted (`isMuted`) or ambient is muted (`isAmbientMuted`), sets the volume to 0 immediately.
     * - Otherwise, gradually restores the volume to `userAmbientVolume` over 2 seconds using `linearRampToValueAtTime`.
     *
     * Notes:
     * - The `linearRampToValueAtTime` method ensures a smooth transition in volume.
     * - This method is called after gameplay events that required reduced ambient sound (e.g., crystal sequence playback).
     *
     * References:
     * - [MDN Web Docs: AudioParam.linearRampToValueAtTime](https://developer.mozilla.org/en-US/docs/Web/API/AudioParam/linearRampToValueAtTime)
     *
     * @example
     * // Restore the ambient volume after a sequence
     * this.restoreAmbientVolume();
     */
    restoreAmbientVolume() {
        if (this.isMuted || this.isAmbientMuted) {
            this.ambientGain.gain.setValueAtTime(0, this.audioContext.currentTime);
            return;
        }
        this.ambientGain.gain.linearRampToValueAtTime(this.userAmbientVolume, this.audioContext.currentTime + 2);
    }

    /**
     * Updates the gain (volume) of all audio channels based on mute and volume settings.
     *
     * This method synchronises the gain values for background, ambient, and effects channels
     * according to the global mute flag (`isMuted`), per-channel mute flags, and user-set volumes.
     * If global mute is active, all channels are silenced. Otherwise, each channel's gain is set to
     * zero if its mute flag is active, or to its user-set volume if not.
     *
     * Behaviour:
     * - Cancels any scheduled gain ramps before setting new values to prevent conflicts (e.g., from linear ramps).
     * - If `isMuted` is true, sets all channel gains to 0.
     * - If `isMuted` is false, sets each channel's gain to 0 if its mute flag is true, or to its user volume otherwise.
     *
     * Notes:
     * - Cancelling scheduled values ensures that mute actions are not overridden by previously scheduled ramps.
     * - This method is called automatically by the setters for mute and volume properties.
     * - Does not update the UI; only affects audio output.
     *
     * References:
     * - [MDN Web Docs: AudioParam.setValueAtTime](https://developer.mozilla.org/en-US/docs/Web/API/AudioParam/setValueAtTime)
     *
     */
    updateMuteStates() {
            // Cancel any scheduled ramps before changing gain values
            this.backgroundGain.gain.cancelScheduledValues(this.audioContext.currentTime);
            this.ambientGain.gain.cancelScheduledValues(this.audioContext.currentTime);
            this.effectsGain.gain.cancelScheduledValues(this.audioContext.currentTime);
        // Global mute overrides all
        if (this.isMuted) {
            this.backgroundGain.gain.setValueAtTime(0, this.audioContext.currentTime);
            this.ambientGain.gain.setValueAtTime(0, this.audioContext.currentTime);
            this.effectsGain.gain.setValueAtTime(0, this.audioContext.currentTime);
        } else {
            this.backgroundGain.gain.setValueAtTime(this.isBackgroundMuted ? 0 : this.userBackgroundVolume, this.audioContext.currentTime);
            this.ambientGain.gain.setValueAtTime(this.isAmbientMuted ? 0 : this.userAmbientVolume, this.audioContext.currentTime);
            this.effectsGain.gain.setValueAtTime(this.isEffectsMuted ? 0 : this.userEffectsVolume, this.audioContext.currentTime);
        }
    }
}

/**
 * Singleton instance of the AudioManager class for managing all game audio.
 *
 * Import and use this instance (`audioManager`) throughout the game to control audio playback,
 * loading, muting, and volume for all channels.
 *
 * @type {AudioManager}
 */
export const audioManager = new AudioManager();