components/session-manager/SessionManager.js

import {
  GainNode,
} from 'isomorphic-web-audio-api';
import {
  isString,
  decibelToLinear,
} from '@ircam/sc-utils';

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

/**
 * The SourceManager component is responsible for creating and managing
 * sessions within a project. At its core, a session the association of
 * a script and of a subset of the project soundfiles.
 *
 * Como players can be associated to session.
 *
 * Como sessions are represented as soundworks SharedState defined by the
 * following parameters:
 * - uuid - Unique id, stable across restarts
 * - name - User defined name
 * - defaultScript - The script associated with the session
 * - soundbank - The list of files associated to the session
 * - mute - Whether the audio output is muted
 * - volume - Volume of the audio output of the session
 */
class SessionManager extends ComoComponent {
  #sessions;
  #sessionAudioRouting = new Map(); // <uuid, { mute: GainNode, volume: GainNode }>

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

    if (!this.como.soundbankManager) {
      throw new Error(`Cannot construct PlayerManager: relies on 'como.soundbankManager'`);
    }
  }

  /**
   * SharedStateCollection of all existing sessions
   */
  get sessions() {
    return this.#sessions;
  }

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

    this.#sessions = await this.como.stateManager.getCollection(`${this.name}:session`);
    this.#sessions.onUpdate((session, updates) => {
      const sessionId = session.get('uuid');

      if (this.#sessionAudioRouting.has(sessionId)) {
        const { mute, volume } = this.#sessionAudioRouting.get(sessionId);

        for (let [key, value] of Object.entries(updates)) {
          switch (key) {
            case 'mute': {
              const gain = value ? 0 : 1;
              mute.gain.setTargetAtTime(gain, this.como.audioContext.currentTime, 0.01);
              break;
            }
            case 'volume': {
              const gain = decibelToLinear(value);
              volume.gain.setTargetAtTime(gain, this.como.audioContext.currentTime, 0.01);
              break;
            }
            case 'soundbank': {
              // @todo - refresh cache
              break;
            }
          }
        }
      }
    });

    this.#sessions.onDetach(session => {
      const sessionId = session.get('uuid');

      if (this.#sessionAudioRouting.has(sessionId)) {
        const { mute, volume } = this.#sessionAudioRouting.get(sessionId);
        volume.disconnect();
        mute.disconnect();

        this.#sessionAudioRouting.delete(sessionId);
      }
    });
  }

  /**
   * Get a session SharedState from its unique id.
   *
   * @param {string} sessionId - Unique id of the session
   * @returns GainNode|null - returns null if session does not exists
   */
  getSession(sessionId) {
    const session = this.#sessions.find(session => session.get('uuid') === sessionId);

    if (!session) {
      return null;
    }

    return session;
  }

  /**
   * Get the audio destination of a session from its unique id.
   *
   * The audio bus is lazily created on first call of this function on a given node.
   *
   * @param {String} sessionId - Unique id of the session
   * @returns GainNode|null - returns null if session does not exists
   */
  getSessionBus(sessionId) {
    if (this.#sessionAudioRouting.has(sessionId)) {
      const { mute } = this.#sessionAudioRouting.get(sessionId);
      return mute;
    }

    const session = this.getSession(sessionId);

    if (!session) {
      return null;
    }

    const mute = new GainNode(this.como.audioContext, {
      gain: session.get('mute') ? 0 : 1,
    });
    const volume = new GainNode(this.como.audioContext, {
      gain: decibelToLinear(session.get('volume')),
    });

    mute.connect(volume).connect(this.como.audioContext.destination);
    this.#sessionAudioRouting.set(sessionId, { mute, volume });

    return mute;
  }

  /**
   * Get the AudioBuffers associated to the session
   *
   * @param {String} sessionId
   * @returns {Object<String, AudioBuffer>}
   */
  async getSessionSoundbank(sessionId) {
    const session = this.getSession(sessionId);

    if (!session) {
      return null;
    }

    const sessionSoundbank = session.get('soundbank');

    return await this.como.soundbankManager.getBuffers(sessionSoundbank);
  }

  /**
   * Create a new session in the current project
   *
   * @param {String} sessionName - Name of the session
   * @returns {String} Unique id of the new session
   */
  async createSession(sessionName) {
    if (!isString(sessionName)) {
      throw new Error('Cannot execute "createSession" on "SessionManager": argument 0 (sessionName) is not a string');
    }

    const exists = this.#sessions.find(session => session.get('name') === sessionName);

    if (exists) {
      throw new Error(`Cannot execute "createSession" on SessionManager: a session with name ${sessionName} already exists`);
    }

    const sessionId = await this.como.requestRfc(this.como.constants.SERVER_ID, `${this.name}:createSession`, { sessionName });
    // synchronous on server-side
    if (this.getSession(sessionId)) {
      return sessionId;
    } else {
      return new Promise((resolve) => {
        const unsubscribe = this.sessions.onAttach(state => {
          if (state.get('id') === sessionId) {
            unsubscribe();
            resolve(sessionId);
          }
        });
      });
    }
  }

  /**
   * Save the session in the filesystem
   *
   * @param {String} sessionId - Unique id of the session
   */
  async persistSession(sessionId) {
    if (!isString(sessionId)) {
      throw new Error('Cannot execute "persistSession" on "SessionManager": argument 0 (sessionId) is not a string');
    }

    return this.como.requestRfc(this.como.constants.SERVER_ID, `${this.name}:persistSession`, { sessionId });
  }

  /**
   * Change the name of the session
   *
   * @param {String} sessionId - Unique id of the session
   * @param {*} newName - New name of the session
   */
  async renameSession(sessionId, newName) {
    if (!isString(sessionId)) {
      throw new Error('Cannot execute "renameSession" on "SessionManager": argument 0 (sessionId) is not a string');
    }

    if (!isString(newName)) {
      throw new Error('Cannot execute "renameSession" on "SessionManager": argument 1 (newName) is not a string');
    }

    return this.como.requestRfc(this.como.constants.SERVER_ID, `${this.name}:renameSession`, { sessionId, newName });
  }

  /**
   * Delete the session
   * - All players within this session will be removed from it
   * - All associated files will be deleted
   *
   * @param {String} sessionId - Unique id of the session
   */
  async deleteSession(sessionId) {
    if (!isString(sessionId)) {
      throw new Error('Cannot execute "deleteSession" on "SessionManager": argument 0 (sessionId) is not a string');
    }

    return this.como.requestRfc(this.como.constants.SERVER_ID, `${this.name}:deleteSession`, { sessionId });
  }
}

export default SessionManager;