components/soundbank-manager/SoundbankManager.js

import {
  AudioBufferLoader,
} from '@ircam/sc-audio';
import {
  isString,
} from '@ircam/sc-utils';

import ComoComponent from '../../core/ComoComponent.js';


// IMPORTANT NOTE
// For now we just load everything based on the state of the filesystem
// and deliver the buffers on demand
// @todo - provide a 'smarter' approach

/**
 * The SoundbankManager component is responsible for loading and retrieving
 * [AudioBuffer](https://developer.mozilla.org/en-US/docs/Web/API/AudioBuffer)s
 * from the projects audio files.
 */
class SoundbankManager extends ComoComponent {
  #filesystem;
  #audioBufferLoader;
  #buffers = {};

  /**
   * @hideconstructor
   * @param {ComoNode} como
   * @param {String} name
   */
  constructor(como, name) {
    super(como, name);
  }

  /** @private */
  get filesystem() {
    return this.#filesystem;
  }

  /** @private */
  async start() {
    await super.start();

    const runtime = this.como.runtime;
    let serverAddress = null;

    if (runtime === 'node') {
      const env = this.como.host.config.env;
      serverAddress = `${env.useHttps ? 'https' : 'http'}://${env.serverAddress || '127.0.0.1'}:${env.port}`;

      if (env.baseUrl) {
        serverAddress += env.baseUrl;
      }
    }

    // @todo
    // - lazily load on demand
    // - on node runtime, load from filesystem whenever possible
    // cf. https://github.com/collective-soundworks/soundworks-plugin-filesystem/issues/11
    this.#audioBufferLoader = new AudioBufferLoader(this.como.audioContext, serverAddress);
    this.#filesystem = await this.como.pluginManager.get(`${this.name}:filesystem`);

    // wait for first load to resolve start promise
    const { promise, resolve } = Promise.withResolvers();

    this.#filesystem.onUpdate(async ({ tree }) => {
      if (tree === null) {
        this.#buffers = {};
        resolve();
        return;
      }

      const urlMap = this.getTreeAsUrlMap();
      const current = Object.keys(this.#buffers);
      const next = Object.keys(urlMap);
      const toAdd = next.filter(item => !current.includes(item));
      const toRemove = current.filter(item => !next.includes(item));
      // clean current loaded buffers
      toRemove.forEach(name => delete this.#buffers[name])
      // add new buffers
      for (let name in urlMap) {
        if (!toAdd.includes(name)) {
          delete urlMap[name];
        }
      }

      const loaded = await this.#audioBufferLoader.load(urlMap);
      Object.assign(this.#buffers, loaded);
      resolve();
    }, true);

    return promise;
  }

  /**
   * Get the list of audio files (mp3 or wav) as filename / url object
   * @see {@link https://github.com/collective-soundworks/soundworks-plugin-filesystem?tab=readme-ov-file#gettreeasurlmap-1}
   * @returns {Object<String, String>}
   */
  getTreeAsUrlMap() {
    // return only wav and mp3 files by default
    return this.filesystem.getTreeAsUrlMap('wav|mp3', true);
  }

  /**
   * Register a callback to be triggered when the underlying filesystem changes
   * @see {@link https://github.com/collective-soundworks/soundworks-plugin-filesystem?tab=readme-ov-file#onupdate-1}
   *
   * @param {Function} callback - Callback function to execute
   * @param {Boolean} executeListener - If true, execute the given callback immediately. (optional, default false)
   */
  onUpdate(...args) {
    return this.filesystem.onUpdate(...args);
  }

  /**
   * Get an AudioBuffer from its filename
   *
   * @stability unstable
   * @param {String} filename - Filename of the related audio buffer
   * @returns {AudioBuffer}
   */
  async getBuffer(filename) {
    if (!isString(filename)) {
      throw new TypeError(`Cannot execute 'getBuffer' on SoundbankManager: argument 1 must be a string`);
    }

    return this.#buffers[filename];
  }

  /**
   * Get a set of AudioBuffers from their filenames
   *
   * @param {Array<String>} fileList - List of filenames to retrieve
   * @returns {Object<String, AudioBuffer>}
   */
  async getBuffers(fileList) {
    if (!Array.isArray(fileList)) {
      throw new TypeError(`Cannot execute 'getBuffers' on SoundbankManager: argument 1 must be an array`);
    }

    fileList.forEach(filename => {
      if (!isString(filename)) {
        throw new TypeError(`Cannot execute 'getBuffers' on SoundbankManager: argument 1 must be an array of string`);
      }
    });

    return fileList.reduce((acc, name) => {
      acc[name] = this.#buffers[name];
      return acc;
    }, {});
  }
}

export default SoundbankManager;