src/store.js

/**
 * The "store" contains state and app-wide methods.
 * @module store
 */

const fs = require("fs");
const path = require("path");
const Layout = require("./layout");
const Lv2 = require("./lv2");
const { pluginInfo } = require("./lv2");
const PubSub = require("pubsub-js");
const { settings } = require("../settings");
const Jalv = require("./jalv");
const Jack = require("./jack_client");
const Nanoid = require("nanoid");
const string_utils = require("./string_utils");
const nanoid = Nanoid.customAlphabet(
  "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
  4
);

/**
 * Notify all subscribers of a given topic.
 *
 * @param {*} topic Topic to target
 * @param {*} data Data to send.
 */
function notifySubscribers(topic, data) {
  PubSub.publish(topic, data);
}

const pluginCatalog = [];
const filteredPluginCatalog = [];
const pluginCategories = ["(All)"];
const rack = [];
let selectedPlugin = {};
let instanceNumber = 0;

/** Tracks App State
 *  @enum
 */
const app = {
  /** Is app initializated and ready to use? */
  INITIALIZED: false,
  APP_ID: nanoid(),
  /** What page the layout is currently showing. @see layout */
  CURRENT_PAGE: 0,
};

/** Tracks several Jack Statuses
 *  @enum
 */
const jack = {
  /** Jack server status and info*/
  JACK_STATUS: {
    status: "---",
    cpu_load: 0,
    block_size: 0,
    realtime: true,
    sample_rate: 0,
  },
  /** Jack Transport and info*/
  TRANSPORT_STATUS: {
    state: "stopped",
    cpu_load: 1.2,
    bar: 5,
    bar_start_tick: 30720.0,
    beat: 4,
    beat_type: 4.0,
    beats_per_bar: 4.0,
    beats_per_minute: 120.0,
    frame: 430592,
    frame_rate: 44,
    tick: 1014,
    ticks_per_beat: 1920.0,
    usecs: 15881132220,
  },
  /** Audio and midi ports availbale to use */
  PORTS: {
    all: [],
    audio: {
      playback: [],
      capture: [],
    },
    midi: {
      playback: [],
      capture: [],
    },
  },
  /** Tracks the connection settings @see settings */
  CONNECTIONS: {
    inputMode: settings.DEFAULT_INPUT_MODE,
    inputLeft: settings.DEFAULT_INPUT_L,
    inputRight: settings.DEFAULT_INPUT_R,
    outputMode: settings.DEFAULT_OUTPUT_MODE,
    outputLeft: settings.DEFAULT_OUTPUT_L,
    outputRight: settings.DEFAULT_OUTPUT_R,
  },
};

/**
 * Sets the current Carrousel Page,  and notify subscribers.
 * @see layout
 *
 */
function setCurrentPage(pageNumber) {
  app.CURRENT_PAGE = pageNumber;
  notifySubscribers("app", app);
  notifySubscribers("jack", jack);
  //   setCategoryFilter("");
  //   notifySubscribers("filteredPluginCatalog", filteredPluginCatalog);

  //   // Also notify rack so it can display it again.
  //   notifySubscribers("rack", rack);
}

/**
 * Sets  Jack status and notify subscribers.
 * This function is used in the jack widget polling.
 * @see jack_client
 * @param {*} [jackStatus=jack.JACK_STATUS]
 * @param {*} [transport_status=jack.TRANSPORT_STATUS]
 * @param {*} [ports=jack.PORTS]
 * @param {*} [connections=jack.CONNECTIONS]
 */
function setJackStatus(
  jackStatus = jack.JACK_STATUS,
  transport_status = jack.TRANSPORT_STATUS,
  ports = jack.PORTS,
  connections = jack.CONNECTIONS
) {
  jack.JACK_STATUS = jackStatus;
  jack.TRANSPORT_STATUS = transport_status;
  jack.PORTS = ports;
  jack.CONNECTIONS = connections;
  notifySubscribers("jack", jack);
}

/**
 * Disconnects and reconnects every plugin;
 *
 */
async function reconectAll() {
  disconnectAll();
  connectAll();
}

/**
 * Disconnects all plugin outputs
 *
 */
function disconnectAll() {
  // If RACK is empty return (In the future could be used for direct monitoring.)
  if (rack.length <= 0) {
    return;
  }

  rack.forEach((plugin, index, arr) => {
    Jack.clearPluginPorts(plugin);
  });
}

/**
 * Process connections for each plugin.
 *
 */
function connectAll() {
  // If RACK is empty return (In the future could be used for direct monitoring.)
  if (rack.length <= 0) {
    return;
  }

  rack.forEach((plugin, index, arr) => {
    wlogDebug(`${index} => ${rack.length}`);

    if (index === 0) {
      Jack.connectPlugins("input", plugin);
    }

    if (index === rack.length - 1) {
      wlogDebug("Connect to output");
      Jack.connectPlugins(plugin, "output");
    } else {
      Jack.connectPlugins(plugin, rack[index + 1]);
    }
  });
}

/**
 * Sets an audio source for a specific channel. It will trigger a total reconnection.
 * @fires notifySubscribers
 * @see settings
 * @param {*} mode Mode should be input or output
 * @param {*} channel 'left' or 'right'
 * @param {*} name The name of the jack audio source.
 */
function setAudioSource(mode, channel, name) {
  if (mode === "input") {
    if (channel === "left") jack.CONNECTIONS.inputLeft = name;
    if (channel === "right") jack.CONNECTIONS.inputRight = name;
  }

  if (mode === "output") {
    if (channel === "left") jack.CONNECTIONS.outputLeft = name;
    if (channel === "right") jack.CONNECTIONS.outputRight = name;
  }
  notifySubscribers("jack", jack);
  wlog(jack.CONNECTIONS.inputLeft);
  wlog(jack.CONNECTIONS.inputRight);
}

/**
 * Set Input/Output mode to mono or stereo.
 * This will trigger a complete reconnection of all plugins
 * @fires notifySubscribers (jack)
 * @param {*} direction 'input | output
 * @param {*} mode mono | stereo
 */
function setAudioSourceMode(direction, mode) {
  // TODO: If mode is input disconnect input with first plugin.
  // If its output disconnect last plugin with outputs.
  // After the modification reconnect.

  jack.CONNECTIONS[direction + "Mode"] = mode;
  notifySubscribers("jack", jack);
  //   connectAll();

  // TODO: RESET CONNECTIONS
}

/**
 *
 * @returns Returns JACK state
 */
function getJackStatus() {
  return jack;
}

function getSelectedPlugin() {
  return selectedPlugin;
}

/**
 * Adds a plugin to the rack.
 * It will reprocess the connections stack, and notify subscribers of __rack__
 * @fires notifySubscribers (rack)
 * @param {string} pluginName The plugin name as appears in the plugin catalog JSON file.
 */
async function addPluginToRack(pluginName) {
  try {
    const p = Lv2.getPluginByName(pluginName);
    const plugin = pluginInfo(p.uri);

    plugin.info = {
      instanceNumber: instanceNumber,
      bypass: false,
      safeName: string_utils.safe(pluginName),
    };

    await Jalv.spawn_plugin(plugin, rack.length);

    instanceNumber++;
    rack.push(plugin);

    // Autoconnect if applicable
    if (settings.AUTO_CONNECT) {
      // This is the first plugin in the rack, connect it to Input.
      if (rack.length === 1) {
        Jack.connectPlugins("input", plugin);
      } else {
        if (settings.AUTO_CONNECT) {
          // Disconnect previously last plugin from output.
          Jack.disconnectPlugins(rack[rack.length - 2], "output");
          // Connect this plugin to the previous
          Jack.connectPlugins(rack[rack.length - 2], plugin);
        }
      }

      // Finnally, connect this plugin to main output
      Jack.connectPlugins(plugin, "output");
    }
    notifySubscribers("rack", rack);
    wlog(`Added ${plugin.name} to rack. (#${rack.length - 1})`);
  } catch (error) {
    wlogError(`Error adding plugin to rack: ${error}`);
    console.trace(error);
  }
}

/**
 * "Safely" Removes all plugins from rack.
 *
 */
function clearRack() {
  for (let index = 0; index < rack.length; index++) {
    removePluginAt(index);
  }
}

/**
 * Removes a plugin according to the index on the rack.
 *
 * @param {*} index Rack Index
 */
function removePluginAt(index) {
  const plugin = rack[index];
  rack.splice(index, 1);
  wlog(`Remove plugin #${index} - ${plugin.name}`);

  Jalv.kill_plugin(plugin, index);

  //   plugin.info.process.disconnect();
  if (selectedPlugin && selectedPlugin.uri === plugin.uri) {
    selectedPlugin = null;
    notifySubscribers("selectedPlugin", selectedPlugin);
  }

  notifySubscribers("rack", rack);

  if (settings.AUTO_RECONNECT) reconectAll();
}

/**
 * Sets the plugin that will be focused to edit.
 *
 * @param {*} pluginName
 */
function setSelectedPluginIndex(index) {
  selectedPlugin = rack[index];
  notifySubscribers("selectedPlugin", selectedPlugin);
}

/**
 * Moves a plugin in the rack. It will trigger a reconnection among (max 3) affected plugins
 *
 * @param {number} rackIndex Rack Index of plugin to move.
 * @param {string} direction ["up"|"down"] Direction to move the plugin.
 * @param {boolean} [max=false] By default it will move 1 unit. If this is true, it will position the plugin on the top/bottom of the rack.
 * @fires notifySubscribers(rack)
 */
function moveRackItem(rackIndex, direction, max = false) {
  const plugin = rack[rackIndex];
  const prev_plugin = rackIndex === 0 ? null : rack[rackIndex - 1];
  const next_plugin = rackIndex >= rack.length - 1 ? null : rack[rackIndex + 1];
  const offset = direction === "up" ? -1 : 1;

  if (direction === "up" && !prev_plugin) return;
  if (direction === "down" && !next_plugin) return;

  rack[rackIndex] = rack[rackIndex + offset];
  rack[rackIndex + offset] = plugin;

  notifySubscribers("rack", rack);

  //   if (prev_plugin) Jack.disconnectPlugins(prev_plugin, plugin, true);
  //   if (next_plugin) Jack.disconnectPlugins(plugin, next_plugin, true);

  //   store.wlogError(`${rackIndex} => ${direction} => ${max}`);
  // TODO: This could be better but for now we will disconnect and reconnect everything.

  if (settings.AUTO_RECONNECT) reconectAll();
}

/**
 * This function will process
 *
 */
function processConnections() {}

/**
 * This function will force connection between 2 plugins. If one of them is mono, adjust as necesary
 *
 * @param {*} src
 * @param {*} dst
 */
function connectPlugins(src, dst) {}

/**
 * Connect ONLY last plugin to master output(s). If one of them is mono, adjust as necesary.
 *
 */
function connectLastToMasterOutputs() {}

function setCategoryFilter(filter = "") {
  if (pluginCategories.includes(filter) && filter !== "(All)") {
    filteredPluginCatalog.length = 0;
    const filteredData = pluginCatalog.filter((plugin) =>
      plugin.categories.includes(filter)
    );
    filteredPluginCatalog.length = 0;
    filteredPluginCatalog.push(...filteredData);
    Layout.renderScreen();
  } else {
    filteredPluginCatalog.length = 0;
    filteredPluginCatalog.push(...pluginCatalog);
    Layout.renderScreen();
  }

  if (app.INITIALIZED) {
    notifySubscribers("filteredPluginCatalog", filteredPluginCatalog);
  }
}

function saveCache(directoryPath = __dirname) {
  wlog("Saving cache...");
  try {
    if (fs.existsSync(path.join(directoryPath, `pluginCatalog.json`))) {
      fs.unlinkSync(path.join(directoryPath, `pluginCatalog.json`));
    }
    if (fs.existsSync(path.join(directoryPath, `pluginCatalog.json`))) {
      fs.unlinkSync(path.join(directoryPath, `pluginCategories.json`));
    }

    const stringifiedData = JSON.stringify(pluginCatalog);
    fs.writeFileSync(
      path.join(directoryPath, `pluginCatalog.json`),
      stringifiedData
    );
    const categories = ["(All)"];
    pluginCatalog.map((item) => {
      const cats = item.categories;
      cats.forEach((cat) => {
        if (!categories.includes(cat)) {
          categories.push(cat);
        }
      });
    });

    pluginCategories.length = 0;
    pluginCategories.push(...categories);

    fs.writeFileSync(
      path.join(directoryPath, `pluginCategories.json`),
      JSON.stringify(categories.sort())
    );

    wlog("Cache Saved.");
  } catch (error) {
    wlog(error);
  }
}

function loadCache(directoryPath = __dirname) {
  const filePath = path.join(directoryPath, `pluginCatalog.json`);
  if (fs.existsSync(filePath)) {
    const jsonData = fs.readFileSync(filePath, "utf8");
    const data = JSON.parse(jsonData);
    pluginCatalog.length = 0;
    pluginCatalog.push(...data);
  }

  const catPath = path.join(directoryPath, `pluginCategories.json`);
  if (fs.existsSync(catPath)) {
    const catData = fs.readFileSync(catPath, "utf8");
    pluginCategories.length = 0;
    pluginCategories.push(...JSON.parse(catData));
  }
}

/**
 * Logs a message in the log-widget
 * @todo Move to the widget module.
 * @param {*} msg message to log.
 */
function wlog(msg) {
  if (app.INITIALIZED) {
    notifySubscribers("wlog", msg);
  } else {
    console.log(msg);
  }
}

/**
 * Logs an error message in the log Widget
 * * @param {*} msg
 */
function wlogError(msg) {
  if (app.INITIALIZED) {
    notifySubscribers("wlogError", msg);
  } else {
    console.trace(msg);
  }
}

function wlogDebug(msg) {
  if (!settings.SHOW_DEBUG_MSG) {
    return;
  }
  if (app.INITIALIZED) {
    notifySubscribers("wlogDebug", msg);
    // logWidget.log(`{green-fg}${msg}{/}`);
  } else {
    console.log(msg);
  }
}

exports.pluginCatalog = pluginCatalog;
exports.pluginCategories = pluginCategories;
exports.setJackStatus = setJackStatus;
exports.getJackStatus = getJackStatus;
exports.saveCache = saveCache;
exports.loadCache = loadCache;
exports.app = app;
exports.setCategoryFilter = setCategoryFilter;
exports.filteredPluginCatalog = filteredPluginCatalog;
exports.setSelectedPluginIndex = setSelectedPluginIndex;
exports.addPluginToRack = addPluginToRack;
exports.rack = rack;
exports.removePluginAt = removePluginAt;
exports.getSelectedPlugin = getSelectedPlugin;
exports.notifySubscribers = notifySubscribers;
exports.setCurrentPage = setCurrentPage;
exports.setAudioSource = setAudioSource;
exports.setAudioSourceMode = setAudioSourceMode;
exports.reconectAll = reconectAll;
exports.clearRack = clearRack;
exports.moveRackItem = moveRackItem;
exports.wlog = wlog;
exports.wlogError = wlogError;
exports.wlogDebug = wlogDebug;