Link Search Menu Expand Document
Table of Contents

Sequencer Example

Sequencer Example

This Sequencer example can be found in the Examples folder of the Gorilla Engine SDK. This is a great starting point for anyone looking to work with a more sophisticated set multiple JavaScript files.

This example demonstrates:

  • How to setup multiple JavaScript files to work with each other.
  • Sequencer Feature overview.

Note: The following is not intended to be a step-by-step guide on how to script a sequencer from scratch. If you are interested in how the example is setup, explore the instrument and script files in Desktop/Gorilla Engine/SDK/Examples/Sequencer. After loading the “Sequencer.inst” in Gorilla Editor you should get an idea of how the “raw” sequencer looks like, and how the custom controls were created.

If you are not familiar with Gorilla Editor, here is a short introduction video. We also provide documentation for Gorilla Editor and Gorilla Script.

Sequencer Features

Our Sequencer example was created to give you a baseline for any state of the art sequencer you might want to build. The the example includes the following features:

  • 8 tracks with up to 16 steps.
  • Randomization for each track.
  • Track selection.
  • Clearing all actives steps, velocities and rolls on an individual track.
  • Shifting all active steps on a track, back and forward.
  • Setting amount of active steps per track.
  • Adjusting swing and nudge for each track.
  • 10 different rates and 3 direction settings per track.
  • Individual velocity for each step.
  • Setting swing across all tracks.
  • Active steps and velocity randomization across all tracks.
  • Clearing of all actives steps, velocities and rolls.

When it comes to your version of a sequencer, you are free to pick and choose which of the mentioned features you want to include in your finished product.

Sequencer - JavaScript

Gorilla Editor

plugin folder

index.js:

This file can be found in Desktop/Gorilla Engine/SDK/Examples/Sequencer/plugin and is the plugins entry point. It loads part_1 and part_2 of the blob files and the yaml file.

The following function initialises sequencer and navigation classes and calls their respective setUpUI() functions:

  setUpUI() {
    this.loadYamlFile()
    this.navigation = new Navigation('navigationFrame', this.instrument)
    this.navigation.setUpUI()

    this.sequencer = new Sequencer('sequencerPage', this.instrument)
    this.sequencer.setUpUI()
    
    this.setSequencerMode()
  }

components

This folder contains Gorilla Engine controls that are used in the example.

geControls.js:

Contains a function get which returns a component (toggle, stepEditor, combo, knobs). This file describes the needed Gorilla Engine controls in the low level. Having them in one file makes it possible to easily change the look and feel of the plugin.

  const trigger = {
  keyboardFocus: {
    color: '00FFFFFF',
  },
  cornerRadius: 2,
  textColor: 'FFFFFF',
  mouseCursorType: "pointing hand",
}

const button = {
  y: 0,
  width: 18,
  height: 18,
  backgroundColor: '4e525e',
  ...trigger,
}

knobComp.js:

This file describes what a knobComponent should look like. It contains a combination of Gorilla Engine knob controls and labels. This makes it easily reusable without duplicating code. The component consists of:

  • The knob itself.
  • A label that displays the name of the parameter which the knob is controling.
  • A label to displays the value of the parameter, while the knob is being interacted with.

ledComp.js:

Contains a LED component which is built using labels. It also contains a listener (if provided in options) which lights up when the instrument value changes. This creates something we call a playhead and gives the user feedback on which step of the Sequencer is currently being played.

     instrument.on(options.value, (value) => {
        const val = instrument[options.value].value
        if(prevLed < maxVal){
            const previous = UI.getControlById(`led_ ${options.id}_${prevLed}`)
            previous.backgroundColor = options.ledLight.offColor  
        } 
        
        if (val === maxVal ) {
            return;
        }
        const current =  UI.getControlById(`led_ ${options.id}_${val}`)
        current.backgroundColor = options.ledLight.onColor
        prevLed = val
    })

navComp

navigation:

The navigation file contains the code for changing the look and feel once either the Steps, Velocity or Rolls tab is clicked. It changes the assets, functionality and over all color of the controls. This is how the changes are done inside rollsClicked():

    this.seqtrigger = UI.getControlByI("seqTextLabel")
    this.seqtrigger.backgroundColor = "282c34"
    this.veltrigger = UI.getControlByI("velTextLabel")
    this.veltrigger.backgroundColor = "282c34"
    this.rollstrigger = UI.getControlById("rollsTextLabel")
    this.rollstrigger.backgroundColor = "39ccc2"

This will change the color of the tabs:

    this.selSeqFrame = UI.getControlById(`seqLabel${this.instrument.select_sequencer_lane.value}`)
    this.selSeqFrame.visible = false
    this.selVelFrame = UI.getControlById(`velLabel${this.instrument.select_sequencer_lane.value}`)
    this.selVelFrame.visible = false
    this.selRollsFrame = UI.getControlById(`rollsLabel${this.instrument.select_sequencer_lane.value}`)
    this.selRollsFrame.visible = true

The following will display the correct control menu for the currently selected Sequencer lane:

    this.sequencerFrame = UI.getControlById("seqNoteFrame")
    this.velFrame = UI.getControlById("seqVelocityFrame")
    this.rollFrame = UI.getControlById("seqRollsFrame")
    this.selPanelSeq = UI.getControlById("selectorPanelSeq")
    this.selPanelRolls = UI.getControlById("selectorPanelRolls")
    this.selPanelVel = UI.getControlById("selectorPanelVel")
    this.rollFrame.visible = true
    this.sequencerFrame.visible = false
    this.velFrame.visible = false
    this.selPanelVel.visible = false
    this.selPanelSeq.visible = false
    this.selPanelRolls.visible = true

The code snippet below takes care of the right Sequencer view and changes between the Steps, Velocity and Rolls mode while also changing the small selector dots on the right side of the sample names.

    this.knobFrameSeq = UI.getControlById("seqKnobFrame")
    this.knobFrameVel = UI.getControlById("velKnobFrame")
    this.knobFrameRolls = UI.getControlById("rollsKnobFrame")
    this.knobFrameVel.visible = false
    this.knobFrameRolls.visible = true
    this.knobFrameSeq.visible = false
    this.curSelSample = UI.getControlById(`Sample_${this.instrument.select_sequencer_lane.value}`)
    this.curSelSample.backgroundColor = "39ccc2" 

sequencer

automationLane.js:

The automationLane.js is responsible for setting the different types of lanes, with exception to the lanes in the Step mode. In the Rolls and Velocity tab it sets values for the positioning and gets the important parameters such as startIndex, endIndex and the lane type from the index.js in Desktop/Gorilla Engine/SDK/Examples/Sequencer/plugin/sequencer.

  createLane(options) {
    let yPos = (8 + options.type.height) * this.index
    this.lane = new UI.BarStepEditor({
      ...options.type,
      id: options.id,
      y: yPos,
      startIndex: options.startIndex,
      endIndex: options.endIndex,
      'value&': options.value,
      paramPath: options.paramPath,
    })
    this.sequencerFrame.appendChild(this.lane)
  }

globalSettings.js:

globalSettings contains all controls that change the functionality of the plugin on a global Level, instead of only working for the selected lanes. Within this class three different knobs get initalized and appended to their corresponding frames. To allow the color change (once a different mode has been selected) the correct frames get set to be visible, with the others not being displayed.

  const swingSeq = getDefaultKnob({
    id: 'globalSwingSeq',
    value: 'instrument.swing',
    label: 'SWING',
    x: 45,
    y: 75,
    width: 96,
    height: 96,
  })
  this.seqKnobFrame.appendChild(swingSeq)

index.js:

The index class contains most of the important code for the Sequencer example. Besides initializing all UI parts (with setUpUI()) this class is also responsible for displaying the right menu for a selected lane, once there is is click in the step editor or via click on the sample name. It also controls the color change of the menu when selecting a different Sequencer mode.

    this.assetchangeMenueSeq = UI.getControlById(`seqLabel${i}`)
    this.assetchangeMenueVel = UI.getControlById(`velLabel${i}`)
    this.assetchangeMenueRolls = UI.getControlById(`rollsLabel${i}`)

    if (this.velFrame.visible == true) {
      this.assetchangeMenueSeq.visible = false
      this.assetchangeMenueVel.visible = true
      this.assetchangeMenueRolls.visible = false

      this.selectedColor = "cc399b"
    }

This is done by looping through each lane and checking for a match between i and the selected lane ID. If it matches, the correct menu will be displayed and every other menu is set to be invisible

As previously mentioned in the automationLane class the options for each lane are beeing passed into the class. This happens inside the Sequencer Main. A loop creates the necessary amount of lanes with the correct positioning parameters and lane type:

setUpVelocityLane() {
  this.sequencerFrame = UI.getControlById('seqVelocityFrame')
  for (var i = 0; i < maxTracks; i++) {
    this.velocitylane = new AutomationLane(i, this.onSeqLaneSelected.bind(this))
    const options = {
      id: `velocityLane${i}`,
      parent: this.sequencerFrame,
      type: velStepEditor,
      startIndex: i * maxSteps,
      endIndex: i * maxSteps + maxSteps,
      value: 'instrument.step_vel',
      paramPath: 'Scripts/1/Step_Vel',
    }

    this.velocitylane.setUpUI(options)
  }
}

laneSettings.js:

The laneSettings class has a number of containers for different sections of the selected lanes menu. The SWING, NUDGE and STEPS knobs are always in one container, to enable the assets change once the Sequencer mode changes. The buttons to shift and clear the selected lane are also initialized and linked to the function that calls the corresponding instrument scripts.

  this.rightBtn = new UI.Trigger({
      id: `rightButton${this.index}`,
      x: 147,
      y: 71,
      height:18,
      width:18,
      images: {
        normal: 'images/Forward Lanes.png',
      },
      keyboardFocus: {
        color: "00ffffff",
      }
    })
    this.controlBackground.appendChild(this.rightBtn)

    this.rightBtn.on('click', this.shiftRight.bind(this))

  shiftRight() {
    this.selectLane()
    this.instrument[`step_shift_${this.index + 1}_r`].value =
      !this.instrument[`step_shift_${this.index + 1}_r`].value
  }

noteLane.js:

The noteLane differs from the automationLane by the type of lane it creates. The noteLane class only creates lanes which are seen in the Steps mode of the Sequencer. This is done to logically group the function of the lanes into differnt classes. The automationLane class modulates any step that is set in the noteLane in different ways, therefore this differentiation has been done.

Presets and Persistance

Below you will find an example on how to implement saving and loading of factory presets, as well as allowing users to save and load custom presets.

This example also shows how to implement plugin persistence. After copying an instance of the Sequencer example within a DAW, all parameters of the copy will have the same state as the original instance. This logic is also applied when saving a session in your DAW and reloading it. The Sequencer example will have the same state as prior to closing the Session.

To prevent redundant code the session saving is done by using parts of the preset code.

Presets:

A combobox makes the presets accessible on the UI:

  const listCombo = new UI.ComboBox({
    ...combo,
    id: 'combo_' + options.id,
    x: prevBtn.x + prevBtn.width,
    y: 2,
    font: "fonts/Roboto-Regular.otf",
    fontSize: 14,
    width: (options.width - prevBtn.width * 2),
    height: options.height-4,
    levelSeperator: "|"
  })
  background.appendChild(listCombo) 
The combo box initializes a level separator, that allows the combo box to have multiple drop downs depending on which value of the drop down the mouse is hovering over. In order for this to work the presets are created with the a tag, that distinguishes whether the preset was created by a user or made by the manufacturer (Factory). The preset name then has to contain the same string as the level separator. In this case “
 onPresetListUpdated() {
        const presetsCombobox = UI.getControlById(presetListComboboxId)
                     
        let factoryArray = this.presetController.getPresetList(false).map((preset) => "Factory |" + preset.name) 
        let userArray = this.presetController.getPresetList(true).map((preset) => "User |" + preset.name)
        presetsCombobox.items = [...factoryArray, ...userArray]
        
        this.updateUISelectedPreset(this.presetController._getSelectedIndex)
    } 

The function shown above is also responsible for initializing all the presets, once the Sequencer example gets loaded or the user has created a new preset. It gets called by the listeners, that are set to the path of the user and the factory preset folders.

   this.presetController.on('factoryPresetListUpdated', () => {
            console.log("********factoryPresetListUpdated******")
            this.onPresetListUpdated() 
        })

        this.presetController.on('userPresetListUpdated', () => {
            console.log("********userPresetListUpdated******") 
            this.onPresetListUpdated() 
        })

The following code snippet shows how exactly the presets are being saved and set once the user selected them.

Saving a preset:

    async savePreset(preset) {
    const presetFilePath = await this.getSavePresetPath(this.userPresetPath)
    const presetName = path.basename(presetFilePath, '.json')
    // Fixme: create a function for this
    this.selectedPreset = {
      name: presetName,
      presetFolder: path.dirname(presetFilePath),
      presetFile: presetFilePath,
      isValid: true,
    }
    const mainPreset = this.getPreset(preset, presetName)
  
    await fs.outputFile(
      presetFilePath,
      JSON.stringify(mainPreset, null, 2)
    )
  } 

This is how to apply the preset, after reading and parsing the preset information.

 // Get an instance of current preset
      if (this.selectedPreset)
        this._previousPreset = JSON.stringify(this.getCurrentAppState()) // incase it goes wrorng fall back
      this.applyPreset(parsedPreset, filePath)
      this._previousPreset = null

Persistance:

As mentioned this example supports persistence for saving its current state and reapplying the same state. After saving and closing a session in a DAW the following function “sessionSaveCallback()” will be called. It will pass information about the current plugin state to the DAW and have it saved in JSON format. This function is also used when a plugin instance gets copied or cloned.

 sessionSaveCallback(additionalState){
    try {
      const state = JSON.stringify({
        //get the current sequencer state
        sequencerState: this.getCurrentPluginState(),
        // Add the additional state (MIDI CC, etc.pp.).
        // The 'additionalState' property has to be a direct member of the state object
        additionalState: JSON.parse(additionalState).additionalState
      })
      // Return the state string
      return state
    } catch (err) {
        console.log(`Error in saveSession callback: ${err.toString()}`)
        // Could not serialize state - return empty state string
        return "";
    }
  }

When opening the example after reloading a session in your DAW (or after copying) the function “sessionLoadCallback()” gets called. This applies the information being passed in JSON, the same way a preset would be applied.

sessionLoadCallback(stateString){
    try {
      const state = JSON.parse(stateString)

      if (state.hasOwnProperty('sequencerState')) {  
        this.onSetPreset(state.sequencerState, true)
      }  

    } catch (error) {
      console.log('Error parsing state')
      console.log(error)
    }
  }

Preset Controller Class:

The preset controller class has a variety of different functions that help with basic preset handling. Examples would be nextPreset() and previousPreset(). Without these functions users would not be able to skip through presets using the buttons on the GUI next to the preset combo box.

 nextPreset() {
    let newIndex = this._getSelectedIndex + 1
    if (newIndex >= this.userPresets.length) {
      newIndex = 0
    }
    this.onPresetSelectChange(newIndex)
  }

  previousPreset() {
    let prevIndex = this._getSelectedIndex - 1
    if (prevIndex < 0) {
      prevIndex = this.userPresets.length - 1
    }
    this.onPresetSelectChange(prevIndex)
  }

Writing and saving a preset file is also handled by this class.

 async savePreset(preset) {
    const presetFilePath = await this.getSavePresetPath(this.userPresetPath)
    const presetName = path.basename(presetFilePath, '.json')
    this.selectedPreset = {
      name: presetName,
      presetFolder: path.dirname(presetFilePath),
      presetFile: presetFilePath,
      isValid: true,
    }
    const mainPreset = this.getPreset(preset, presetName)
  
    await fs.outputFile(
      presetFilePath,
      JSON.stringify(mainPreset, null, 2)
    )
  }

Reading and parsing a preset file from disk is also handled by this class using the readPresetFile() function. It gets the current state of the plugin, so it has a fallback in case something goes wrong while setting the preset. The parsed preset state (in this case a .json file) is set using applyPreset().

try {
      stringContent = await fs.readFile(filePath, 'utf8')
    } 
catch (error) {
      console.log(error)
}

//Parse File content 
try {
  parsedPreset = JSON.parse(stringContent)
} catch (error) {
  console.log(error)
}

 try {
  // Get an instance of current preset
  if (this.selectedPreset)
    this._previousPreset = JSON.stringify(this.getCurrentAppState()) //incase it goes wrong fall back
  this.applyPreset(parsedPreset, filePath)
  this._previousPreset = null
} catch (error) {
  console.log(error)
}

When loading the Sequencer Example most of the preset initialization is done by setPresetMeta(). It ensures that the user preset folder and the factory preset folder exist and sets them to the correct path. Then it acquires all existing presets and is continuously watching for changes in their directories.

  setPresetMeta(presetMeta) {
    this.presetFileExt = presetMeta.fileExt
    this.factoryPresetPath = presetMeta.factoryPath
    this.userPresetPath = presetMeta.userPath

    this.ensurePresetFolder(this.factoryPresetPath)
    this.ensurePresetFolder(this.userPresetPath)

    this.getPresets(this.factoryPresetPath, false) 
    this.getPresets(this.userPresetPath, true)
   //watch the User folder 
    this.watchUserPresetBaseFolder(this.userPresetPath)
  } 

getPresets() is the function that actually extracts the presets from their directories. It needs to know the file path and whether or not the presets are user or factory presets. This is due to the function acquiring all presets from both folders at once. It only returns what is currently required to separate them logically.

async getPresets(presetsBaseFolder, isUser) {  
    try {
      const files = await fs.readdir(presetsBaseFolder) // read all files in the basePresetFolder
      const presets = await Promise.all(
        await files.map(async (file) => {
          const fullPath = path.join(presetsBaseFolder, file)
          return {
            name: path.basename(file, this.presetFileExt),
            presetFile: fullPath,
            isValid: await this.checkIfPathIsValidPreset(fullPath)
          }
        })
      )
      
      if(isUser){
        this.userPresets = presets.filter((preset) => preset.isValid)
      } else{
        this.factoryPresets = presets.filter((preset) => preset.isValid)
      }
      
      this.updateUIPresetsList(isUser)
    } catch (error) {
      console.log('Failed to load the presets')
      console.log(error)
    }
  }

watchUserPresetBaseFolder() updates the preset list once a new preset has been created. This is done by using fswatch.

 watchUserPresetBaseFolder(presetsBaseFolder) {
    fs.watch(presetsBaseFolder, { recursive: true }, (eventType, fileName) => {
      //Trigger only when there is a change to a .json
      if (!fileName.includes('.DS_Store')) {
        this.getPresets(presetsBaseFolder, true)
      }
    })
  }

updateUIPresetsList() lists existing presets and tells you whether or not the last added preset was a factory or a user preset.

updateUIPresetsList(isUser) {
    this.presetPool = [...this.factoryPresets, ...this.userPresets]
    isUser? this.emit("userPresetListUpdated"): this.emit("factoryPresetListUpdated")
  }

Blob relocation

Based on a plugins content size a user might want to relocate the blob files to their preferred file location. This example demonstrates how this can be handled.

findBlobPath() is called from loadBlobAndInstrument() and returns the current path to the blob location. It initially checks whether the expected blobs are available in the default location. If not, it displays a popup dialog informing the user that the expected blob was not found and a dialog would appear for the user to point to the correct location.

try {
      await GorillaEngine.showNativeMessageBox({ title: "Plugin content not found", message: `A dialog will come up.\nPlease navigate to ${expectedBlobName}`, iconType: "info" })
    } catch (error) {
        throw new Error('error occured showing user a dialog to choose blob file')
    }

After closing the dialog an OS native file chooser pops up and the user can select the blob location. After that the path is saved as a JSON settings file to be used when the user instantiates the plugin in the future.

const files = GorillaEngine.openFileChooserSync({hint: `Please navigate to ${expectedBlobName}`, allowedExtensions: "*.blob"})
if( files.length != 1 || path.basename(files[0]) !== expectedBlobName ) {
    GorillaEngine.showNativeMessageBox({ title: "Plugin content not found", message: `${expectedBlobName} couldn't be found.\nPlease consider reinstalling ${PluginName}`, iconType: "warning" })
} else {
    result = files[0]
    fs.ensureFileSync(this.settingsFilePath)
    fs.writeFileSync(this.settingsFilePath, JSON.stringify({ blobPath: result }))   
}

The next time findBlobPath() is called it will check the default blob location. If the expected blob is not found it will call checkIfBlobIsRelocated(), which checks for a settings file and reads the blobPath property. If the expected blob is present at the location this is returned as the current blob path, else the processes explained above will be triggered.

checkIfBlobIsRelocated(){
    let blobFound = false
    const pluginSettings = this.settingsFilePath
    let blobPath = ""
    if( fs.existsSync(pluginSettings) ) {
      try {
          const settings = JSON.parse(fs.readFileSync(pluginSettings))
          if( settings.hasOwnProperty("blobPath") ) {
            blobFound = this.checkIfBlobExists(settings.blobPath) 
            blobPath = settings.blobPath
          }
      } catch (e) {
          console.log(`Error while reading global settings file: ${e}`)
      }
    }
    return {"exists": blobFound, "path": blobPath}  
  }