src/jalv.js

/**
 * jalv is the underliying host powering calvo.
 * Each plugin is loaded on a new process and connected to previous/next plugins.
 * This module contains process spawn methods.
 * @module jalv
 */

const Layout = require("./layout");
const { spawn } = require("child_process");
const store = require("./store");
const PubSub = require("pubsub-js");
const { settings } = require("../settings");

// Each time a new plugin is seclected query jalv for controls info.
PubSub.subscribe("selectedPlugin", (msg, plugin) => {
  addToQueue(plugin, "controls", "controls");
});

/**
 * Spawns a plugin. It will execute jalv and load the plugin. ALl communcations can be reachead via the process now at plugin.process
 *
 * @param {plugin} plugin
 * @param {*} rackIndex
 * @returns The nodejs child process.
 */
async function spawn_plugin(plugin, rackIndex) {
  const sleep = require("util").promisify(setTimeout);

  let processSpawned = false;
  // We need to loose the buffer to get a fast response:
  // https://gitlab.com/drobilla/jalv/-/issues/7
  const process = spawn("stdbuf", [
    "-oL",
    "-eL",
    "jalv",
    "-p",
    "-t",
    "-n",
    `calvo_${store.app.APP_ID}_${plugin.info.instanceNumber}_${plugin.info.safeName}`,
    plugin.uri,
  ]);
  process.stdout.setEncoding("utf8");
  process.stdin.setEncoding("utf8");
  process.stderr.once("data", function (msg) {
    processSpawned = true;
  });

  process.stdout.once("data", function (msg) {
    processSpawned = true;
  });
  //   process.stdout.on("data", function (msg) {
  //     store.wlog(`[#${rackIndex}] ${msg}`);
  //   });
  //   process.stderr.on("data", function (msg) {
  //     store.wlogError(`[#${rackIndex}] ${msg}`);
  //   });

  //   process.on("message", function (msg) {
  //     store.wlog(`[#${rackIndex}] ${msg}`);
  //   });

  let retries = 0;
  while (!processSpawned && retries < 5) {
    await sleep(100);
  }

  if (!processSpawned) {
    store.wlogError("Could not load plugin");
    return null;
  }

  plugin.info.queue = {
    set: [],
    controls: [],
  };

  plugin.process = process;
  plugin.info.processQueueInterval = setInterval(
    () => processQueue(plugin),
    settings.JALV_POLLING_RATE
  );
  return process;
}

/**
 * Gets the control values for a jalv process.
 *
 * @param {plugin} plugin Plugin instance (from rack)
 * @param {string} type Type of control to get('controls'|'monitor' )
 * @returns the control value as json: ctrlName: ctrlValue
 */
async function getControls(plugin, type) {
  addToQueue(plugin, type, type);
}

/**
 * On calvo, Jalv host is spawned as a child process and we take the values from stdout.
 * To provide at least, a very basic monitoring, a queue system is necesary.
 * The queue will 'tick' every x ms according to JALV_POLLING_RATE setting. each tick will process a command and wait for the response.
 * Different commands have different priorities:
 * SET = HIGH, controls = MID, monitors = LOW
 * As monitors spams the stdout buffer it was neeeded to only call it when nothing else is needing the buffer.
 * @see settings
 *
 * @param {plugin} plugin
 * @returns null if no Selected plugin or the process is busy (wating for a response)
 */
async function processQueue(plugin) {
  // If this plugin is not the selected. Do not do anything.
  if (store.getSelectedPlugin() !== plugin) return;
  if (!plugin) return;

  // If still we havent got a stdout from last command, skip this tick.
  if (plugin.process.busy) return;

  // Top priority for SET commands
  if (plugin.info.queue.set.length > 0) {
    const result = await writeWait(plugin.process, plugin.info.queue.set[0]);
    plugin.info.queue.set.shift();
    store.wlogDebug(JSON.stringify(result));
    // Mid priority for GET controls
  } else if (plugin.info.queue.controls.length > 0) {
    const result = await writeWait(plugin.process, "controls");
    plugin.info.queue.controls.shift();
    plugin.info.controls = result;
    store.wlogDebug(JSON.stringify(result));
    store.notifySubscribers("pluginControlsChanged", plugin);
  } else {
    // At last, if nothing else is printing output, we can now get some monitor info.
    if (settings.JALV_MONITORING && plugin.ports.control.output.length > 0) {
      const result = await writeWait(plugin.process, "monitors");
      // Sometimes we cannot get info and we get corrupted result, lets use the previous value for now.
      if (result) {
        plugin.info.monitors = result;
        store.notifySubscribers("pluginMonitorsChanged", plugin);
      }
    }
  }
}

/**
 * Write to a process stdin and wait for a response on stdout.
 * While waiting, it will set the process.busy flag, so the queue will not advance until this function is finished.
 * *
 * @param {pluginJalvProcess} process The plugin instance JALV process.
 * @param {string} command The command to execute on JALV
 * @param {number} [maxRetries=10] How many time retry before giving up.
 * @param {number} [retriesWait=5] How many ms to wait before each retry.
 * @returns A JSON file with the (parsed) output for the given command.
 */
async function writeWait(process, command, maxRetries = 10, retriesWait = 5) {
  const sleep = require("util").promisify(setTimeout);
  process.busy = true;

  let done = false;
  let retries = 0;
  let result = "";
  process.stdin.write(command + "\n");
  process.stdout.once("data", function (msg) {
    result = msg;
    done = true;
  });

  while (!done && retries < maxRetries) {
    retries++;
    await sleep(retriesWait);
  }

  if (!done) {
    store.wlogError("Error in write wait, for " + command);
    process.busy = false;
    return null;
  }
  resultJSON = jalvStdoutToJSON(result, command);
  process.busy = false;
  return resultJSON;
}

/**
 * Add a command to the plugin instance queue
 *
 * @param {plugin} plugin The plugin instance.
 * @param {string} type Can be 'set, controls, monitors'
 * @param {string} command command to execute.
 * @returns null if no plugin is specified
 */
function addToQueue(plugin, type, command) {
  if (!plugin) return;
  plugin.info.queue[type].push(command);
}

/**
 * Set a value on a control  (in queue)
 *
 * @param {plugin} plugin Plugin
 * @param {string} control Control name. Uses `symbol` property of LV2 spec.
 * @param {number} value Value to assign.
 * @returns null if no plugin is specified
 */
function setControl(plugin, control, value) {
  if (!plugin) return;
  const command = `set ${control.symbol} ${value}`;
  addToQueue(plugin, "set", command);
}

/**
 * Formats and convert a JALV kvp stdout (CONTROL = VALUE) into a json object.
 *
 * @param {string} str JALV stoud to format
 * @param {string} command the command invoked which resulted in this output.
 * @returns a JSON object. key: value
 */
function jalvStdoutToJSON(str, command) {
  const obj = { jalv_command: command };
  let result = str.replace(">", "").trim();
  result.split("\n").forEach((line) => {
    const kvp = line.split("=");
    const k = kvp[0].toString().replace(">", "").trim();
    const v = kvp[1].replace(">", "");
    obj[k] = v;
  });
  return obj;
}

/**
 * Kills the plugin process.
 *
 * @param {pluginProcess} process
 * @param {number} rackIndex
 */
function kill_plugin(plugin, rackIndex) {
  try {
    clearInterval(plugin.info.processQueueInterval);
    plugin.process.kill();
  } catch (error) {
    store.wlogError(`[#${rackIndex}] ${error}`);
  }
}

/**
 * Writes a message to a plugin process.
 * @param {jalvProcess} process The jalv process to write into. (plugin.process)
 * @param {string} msg The Jalv prompt supports several commands for interactive control.
 * @example <caption>JALV Commands</caption>
 *       help              Display help message
 *       controls          Print settable control values
 *       monitors          Print output control values
 *       presets           Print available presets
 *       preset URI        Set preset
 *       set INDEX VALUE   Set control value by port index
 *       set SYMBOL VALUE  Set control value by symbol
 *       SYMBOL = VALUE    Set control value by symbol
 */
function write(process, msg) {
  process.stdin.write(msg + "\n");
}

exports.spawn_plugin = spawn_plugin;
exports.kill_plugin = kill_plugin;
exports.write = write;
exports.getControls = getControls;
exports.setControl = setControl;