Source: modules/sound.js

/**
 * @private
 * A wrapper for a loaded sound sample, holding its buffer and default properties.
 */
class SoundSample {
    /** @type {AudioBuffer|null} */
    buffer = null;
    /** @type {boolean} */
    loaded = false;
    /** @type {boolean} */
    loop = false;
    /** @type {number} */
    volume = 1.0;
    /** @type {number} */
    pitch = 1.0;
    /** @type {number} */
    pan = 0.0;
    /** @private */
    #audioContext;

    constructor(audioContext) {
        this.#audioContext = audioContext;
    }

    /** @returns {Promise<SoundSample>} */
    load(path) {
        return fetch(path) // Return the whole chain
            .then(response => {
                if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
                return response.arrayBuffer();
            })
            .then(arrayBuffer => this.#audioContext.decodeAudioData(arrayBuffer))
            .then(audioBuffer => {
                this.buffer = audioBuffer;
                this.loaded = true;
                return this; // Resolve with the SoundSample instance
            })
            .catch(e => {
                const error = new Error(`jBB Sound: Failed to load sound at ${path}`);
                console.error(error, e);
                throw error; // Re-throw to make Promise.all fail
            });
    }
}

/**
 * Manages all audio operations, emulating the BlitzBasic sound system.
 */
export class Sound{
	/** @private */
	#audioContext;
    /** @private @type {Array<SoundSample|null>} */
    #sounds = [null];
    /** @private @type {Array<object|null>} */
    #channels = [null];
    /** @private @type {HTMLAudioElement} */
    #musicElement;

    constructor() {
        this.#musicElement = new Audio();
    }

    /**
	 * @private
     * Initializes the AudioContext. Must be called after a user interaction.
     */
    #initContext() {
        if (!this.#audioContext && (window.AudioContext || window.webkitAudioContext)) {
            this.#audioContext = new (window.AudioContext || window.webkitAudioContext)();
        }
    }

    /**
	 * @private
     * Finds a free slot in a handle array.
     * @param {Array} handleArray The array to search.
     */
    #getFreeHandle(handleArray) {
        for (let i = 1; i < handleArray.length; i++) {
            if (handleArray[i] === null) return i;
        }
        return handleArray.push(null) - 1;
    }

    /**
     * Loads a sound effect into memory.
     * @param {string} path The path to the sound file.
     * @returns {{handle: number, promise: Promise}} An object containing the sound handle and the loading promise.
     */
    loadSound(path) {
        this.#initContext();
        if (!this.#audioContext) {
            console.warn("jBB Sound: AudioContext not supported. Sound functions will be disabled.");
            return { handle: 0, promise: Promise.resolve() };
        }
        const handle = this.#getFreeHandle(this.#sounds);
        const soundSample = new SoundSample(this.#audioContext);
        const promise = soundSample.load(path);
        this.#sounds[handle] = soundSample;
        return { handle, promise };
    }

    /**
     * Releases a sound from memory.
     * @param {number} handle The sound handle to free.
     */
    freeSound(handle) {
        if (this.#sounds[handle]) {
            this.#sounds[handle] = null;
        }
    }

    /**
     * Sets a sound to loop by default.
     * @param {number} handle The sound handle.
     * @param {boolean} [loop=true] True to enable looping.
     */
    loopSound(handle, loop = true) {
        const sound = this.#sounds[handle];
        if (sound) sound.loop = loop;
    }

    /**
     * Sets the default volume for a sound.
     * @param {number} handle The sound handle.
     * @param {number} volume The volume (0.0 to 1.0).
     */
    soundVolume(handle, volume) {
        const sound = this.#sounds[handle];
        if (sound) sound.volume = volume;
    }

    /**
     * Sets the default pitch for a sound.
     * @param {number} handle The sound handle.
     * @param {number} pitch The pitch multiplier (1.0 is normal).
     */
    soundPitch(handle, pitch) {
        const sound = this.#sounds[handle];
        if (sound) sound.pitch = pitch;
    }

    /**
     * Sets the default stereo panning for a sound.
     * @param {number} handle The sound handle.
     * @param {number} pan The pan value (-1.0 is left, 0.0 is center, 1.0 is right).
     */
    soundPan(handle, pan) {
        const sound = this.#sounds[handle];
        if (sound) sound.pan = Math.max(-1, Math.min(1, pan)); // Clamp to -1 to 1 range
    }

    /**
     * Plays a loaded sound.
     * @param {number} soundHandle The sound handle to play.
     * @returns {number} A channel handle for this playing instance of the sound, or 0 on failure.
     */
    playSound(soundHandle) {
        if (!this.#audioContext) return 0;

        // Attempt to resume context if it was suspended by browser policy.
        if (this.#audioContext.state === 'suspended') {
            this.#audioContext.resume();
        }

        const sound = this.#sounds[soundHandle];
        if (!sound || !sound.loaded) return 0; // Don't play if not loaded yet.

        const source = this.#audioContext.createBufferSource();
        source.buffer = sound.buffer;
        source.loop = sound.loop;
        source.playbackRate.value = sound.pitch;

        const gainNode = this.#audioContext.createGain();
        gainNode.gain.value = sound.volume;

        const panNode = this.#audioContext.createStereoPanner();
        panNode.pan.value = sound.pan;

        source.connect(gainNode);
        gainNode.connect(panNode);
        panNode.connect(this.#audioContext.destination);
        source.start(0);

        const channelHandle = this.#getFreeHandle(this.#channels);
        const channel = {
            source: source,
            gainNode: gainNode,
            panNode: panNode,
            isPlaying: true
        };
        this.#channels[channelHandle] = channel;

        source.onended = () => {
            channel.isPlaying = false;
            this.#channels[channelHandle] = null; // Free up the channel handle.
        };

        return channelHandle;
    }

    /**
     * Stops a playing sound on a specific channel.
     * @param {number} channelHandle The channel handle returned by `playSound`.
     */
    stopChannel(channelHandle) {
        const channel = this.#channels[channelHandle];
        if (channel && channel.isPlaying) {
            channel.source.stop(0);
        }
    }

    /**
     * Changes the volume of a currently playing sound on a specific channel.
     * @param {number} channelHandle The channel handle.
     * @param {number} volume The new volume (0.0 to 1.0).
     */
    channelVolume(channelHandle, volume) {
        const channel = this.#channels[channelHandle];
        if (channel) {
            channel.gainNode.gain.setTargetAtTime(volume, this.#audioContext.currentTime, 0.01);
        }
    }

    /**
     * Checks if a sound is still playing on a specific channel.
     * @param {number} channelHandle The channel handle.
     * @returns {boolean}
     */
    channelPlaying(channelHandle) {
        const channel = this.#channels[channelHandle];
        return !!(channel && channel.isPlaying);
    }

    /**
     * Streams and plays a music file.
     * @param {string} path The path to the music file.
     * @param {boolean} [loop=true] True to loop the music.
     */
    playMusic(path, loop = true) {
        this.#musicElement.src = path;
        this.#musicElement.loop = loop;
        this.#musicElement.play().catch(e => console.error("jBB Music: Playback failed. User interaction might be required.", e));
    }

    /**
     * Stops the currently playing music and rewinds it to the beginning.
     */
    stopMusic() {
        this.#musicElement.pause();
        this.#musicElement.currentTime = 0;
    }

    /**
     * Pauses the currently playing music.
     */
    pauseMusic() {
        this.#musicElement.pause();
    }

    /**
     * Resumes paused music.
     */
    resumeMusic() {
        if (this.#musicElement.src && this.#musicElement.paused) {
            this.#musicElement.play().catch(e => console.error("jBB Music: Resume failed.", e));
        }
    }

    /**
     * Sets the volume for the music stream.
     * @param {number} volume The volume (0.0 to 1.0).
     */
    musicVolume(volume) { // Volume from 0.0 to 1.0
        this.#musicElement.volume = Math.max(0, Math.min(1, volume));
    }
}