Extending AI Knob functionality

Ai knob is a great tool to have when in a hybrid setup by which I mean having plugin windows open and setting a hardware knob/fader adjusting the currently focused parameter.
This functionality requires from the VST vendors, to expose to Cubase which parameter is at which plugin window position. Unfortunately not everyone is following this, so users who don’t know the technical details behind this (and they shouldn’t) tend to think that this feature is somehow broken.
Anyway, I’ve prepared a MIDI Remote as an example of how we can workaround this issue.
It is not however identical to the AI knob, in that while the AI knob knows very well the exposed parameter of a VST (which supports it) even if we hover our mouse upon it, in the implementation I’ve made, this is not possible. We have to click on the parameter, in order for the script to understand that there is a change in which parameter is to be controlled. Nevertheless, it’s still usable, at least so I saw in some tests I’ve made here, and at the same time reliable enough.
In the script here, I’ve combined both the AI knob and what I call AI Knob Alt. The idea is simple:
If the AI knob finds a parameter, then we’re all good, we let it handle the parameter. If however it doesn’t, the AI knob (alt) now waits from us to click on a parameter in order to properly control it. Ai knob and its alternative are handled in two different subPages.
At the same time, I extended the Value Lock functionality to these plugins that do not follow the AI knob functionality.
There are also two additions, that I thought would be useful:

  • I’ve added Normal→Fine Resolution toggle (and gate) buttons. When we set to Fine resolution, the steps used by our knob are set at 1/1023, instead of the quite usual 1/127. I find this handy, because there are many times we just need to tweak just a tiny bit.
  • The second addition, is what I call “Recall” (in the UI of the MIDI Remote). When we are using the AI knob Alternative, the script stores the last touched parameter for every single plugin window we previously focused. This is cool, since it allows for example, to control the “Cutoff” on a VSTi, then turn to an FX and control a “Delay” and then return to the VSTi, and without even having to re-focus the “Cutoff”, it will be recalled and we can continue tweaking it (unless of course we navigate to a different parameter).

Here’s the midiRemote file for anyone wanting to give it a try by installing it in Cubendo:
Test_AI Knob Alt.midiremote (5.3 KB)

Inside the script you will find the MIDI Port and the CCs used, and you can obviously alter them in order to fit your controller.

The code for anyone interested, without the need to install the script:

// @ts-nocheck
var midiInputPortName="Loupedeck CT" //Place the exact name (case sensitive) of your MIDI Input Port here

var knobTypes={absolute: 0, relativeBinaryOffset: 1, relativeSignedBit: 2, complement2s: 3} //Used while defining our knobs. Check your controller's MIDI Implementation Chart for choosing the correct ones for the knobs below

var ccChannel=0 //The MIDI Channel of the CC messages

var ccKnobMain=1 //This alters the focused parameter in the first resolution step (default: 1/127)
var knobMainType=knobTypes.absolute //Defining the type of our main knob

var ccKnobFine=2 //Altering using the "Fine" resolution step (default: 1/1023)
var knobFineType=knobTypes.absolute //Defining the type of our "Fine" knob

var ccButtonLock=3 //Used to toggle locking the currently focused parameter. If Lock is on, we continue to alter this parameter, no matter where the focus gets

var ccButtonGateResolution=4 //Used to "Gate" to the "Fine" Resolution. While holding this button, the "Fine" Resolution will be used
var ccButtonToggleResolution=5 //Toggling the Resolution

var ccButtonDecrease=6 //Decrease the parameter value. If "Fine" is on, the decrease will comply to the "Fine" Resolution setting
var ccButtonIncrease=7 //Increase the parameter value. If "Fine" is on, the decrease will comply to the "Fine" Resolution setting

var ccButtonDecreaseFine=8 //Decrease the parameter value in "Fine" Resolution
var ccButtonIncreaseFine=9 //Increase the parameter value in "Fine" Resolution

var ccButtonRecallParameter=10 //This one is interesting. If toggled to "On", the script will remember the last parameter we touched (clicked) on any plugin Window, and recall it upon reshowing it,so that we can continue altering its value without even focusing the parameter


var recallObjectParameter=false  //The default for recalling the last touched (clicked) parameter on a plugin Window


var paramResolutions=[1/127,1/1023] //The two types of resolutions. The second one is the "Fine" one. You can experiment with these values, especially the "Fine" one

var paramResIndex=0 //Initial resolution index (Default: 0)
var currentResIndex=0

var recallParametersMap={} //Used for holding the tag of the last touched (clicked) parameter for every previously focused plugin window

var paramIsLocked=false //Register of the lock state we define

var currentPluginPath="" //The path of the currently focused plugin

var focusedObjectID=-1 //This is the variable holding the currently focused object
var focusedParamTag=-1 //This is the variable holding the currenly touched (clicked) parameter in the focused object

var channelsUniqueNames=[] //We need this for properly getting the object IDs when checking the focused object path
var channelsIds=[] //The IDs of the (mixer) channels

var midiremote_api = require('midiremote_api_v1')
var deviceDriver = midiremote_api.makeDeviceDriver("Test", "AI Knob Alt", "mc")

var midiInput = deviceDriver.mPorts.makeMidiInput("anInput")

var detectionUnit=deviceDriver.makeDetectionUnit()
detectionUnit.detectSingleInput(midiInput).expectInputNameEquals(midiInputPortName)

	 
var surface = deviceDriver.mSurface

var knobFinal=surface.makeKnob(0,0,3.5,3.5) //This should reflect the value of the currently edited parameter

//createVar function arguments: Variable Name, CC Channel, CC, knobType
var knob=createVar("knob",ccChannel,ccKnobMain,knobMainType) 

var knobFine=createVar("knobFine",ccChannel,ccKnobFine,knobFineType)

var buttonLock=createVar("buttonLock",ccChannel,ccButtonLock,-1)

var buttonGateKnobResolution=createVar("buttonResGate",ccChannel,ccButtonGateResolution,-1)
var buttonToggleKnobResolution=createVar("buttonResToggle",ccChannel,ccButtonToggleResolution,-1)

var buttonDec=createVar("buttonDec",ccChannel,ccButtonDecrease,-1)
var buttonInc=createVar("buttonInc",ccChannel,ccButtonIncrease,-1)

var buttonDecFine=createVar("buttonDecFine",ccChannel,ccButtonDecreaseFine,-1)
var buttonIncFine=createVar("buttonIncFine",ccChannel,ccButtonIncreaseFine,-1)

var buttonRecallObjectParameter=createVar("buttonRecallObjectParameter",ccChannel,ccButtonRecallParameter,-1)

var buttonPseudoAI=surface.makeCustomValueVariable("buttonPseudoAI")

var ledLocked=surface.makeLamp(4,0.5,0.5,0.5) //Showing whether we have Lock-parameter On/Off
var labelLocked=surface.makeLabelField(4.5,0.5,1.5,0.5)

var ledFine=surface.makeLamp(4,1.5,0.5,0.5) //Showing whether we have Fine Resolution On/Off
var labelFine=surface.makeLabelField(4.5,1.5,1.5,0.5)

var ledRecall=surface.makeLamp(4,2.5,0.5,0.5) //Showing whether we have Parameter Recalling On/Off
var labelRecall=surface.makeLabelField(4.5,2.5,1.5,0.5)

var labelSubPage=surface.makeLabelField(0,4,2,0.5)

var mapping = deviceDriver.mMapping

var currentMapping //Abstract ActiveMapping

var page=mapping.makePage("Default")
var subPagesArea=page.makeSubPageArea("subPagesArea")
var subPages=[subPagesArea.makeSubPage("AI"),subPagesArea.makeSubPage("AI Alt")]

page.setLabelFieldText(labelLocked,"Lock")
page.setLabelFieldText(labelFine,"Fine")
page.setLabelFieldText(labelRecall,"Recall")

page.setLabelFieldSubPageArea(labelSubPage,subPagesArea)

var aiHostValue=page.mHostAccess.mMouseCursor.mValueUnderMouse 
var aiAltHostValue=page.mCustom.makeHostValueVariable("aiAltHostValue")

page.makeValueBinding(knobFinal.mSurfaceValue,aiHostValue).setSubPage(subPages[0])

page.makeValueBinding(buttonPseudoAI,aiAltHostValue).setSubPage(subPages[0])


page.makeValueBinding(knobFinal.mSurfaceValue,aiAltHostValue).setSubPage(subPages[1])
page.makeValueBinding(buttonPseudoAI,aiHostValue).setSubPage(subPages[1])

var aiLockedHostValue=page.mHostAccess.mMouseCursor.mValueLocked
page.makeValueBinding(ledLocked.mSurfaceValue,aiLockedHostValue)

var aiDisabled=0
var aiCurrentProcessValue=0
var aiCurrentTitle1=""
var aiCurrentTitle2=""

aiHostValue.mOnTitleChange=function(activeDevice,activeMapping,title1,title2){
	
	var prevDisabled=aiDisabled
	aiDisabled= title2=="" ? 1 : 0
	
	if(aiDisabled!=prevDisabled){

		subPages[aiDisabled].mAction.mActivate.trigger(activeMapping)
		
	}

	if(aiDisabled==0 && (title1!=aiCurrentTitle1 || title2!=aiCurrentTitle2)){

		var currentKnobFinalValue=knobFinal.mSurfaceValue.getProcessValue(activeDevice)

		if(currentKnobFinalValue!=aiCurrentProcessValue){

			knobFinal.mSurfaceValue.setProcessValue(activeDevice,aiCurrentProcessValue)

		}

	}

	aiCurrentTitle1=title1 
	aiCurrentTitle2=title2 

}

aiHostValue.mOnProcessValueChange=function(activeDevice,activeMapping,value){
	
	aiCurrentProcessValue=value

}

var fqc=page.mHostAccess.mFocusedQuickControls

var daMixer=page.mHostAccess.makeDirectAccess(page.mHostAccess.mMixConsole)
var daFQC=page.mHostAccess.makeDirectAccess(fqc)

page.mOnActivate=function(activeDevice,activeMapping){

	currentMapping=activeMapping
	daMixer.activate(activeMapping)
	daFQC.activate(activeMapping)

}

page.mOnDeactivate=function(activeDevice,activeMapping){

	daMixer.deactivate(activeMapping)
	daFQC.deactivate(activeMapping)
	
}

daMixer.mOnObjectChange=function(activeDevice,activeMapping,objectID){

	if(objectID==daMixer.getBaseObjectID(activeMapping)){

		var size=daMixer.getNumberOfChildObjects(activeMapping,objectID)-1 //The last element is the selected channel, I exclude it here

		if(size!=channelsUniqueNames.length){

			channelsUniqueNames=[]
			channelsIds=[]

			for(var i=0;i<size;i++){

				var channelID=daMixer.getChildObjectID(activeMapping,objectID,i)
				var name=daMixer.getObjectUniqueName(activeMapping,channelID)
				var uniqueID=daMixer.getObjectUniqueIDString(activeMapping,channelID)
				var channelName="VST Mixer\\Channels\\"+name
				channelsUniqueNames.push(channelName)
				channelsIds.push(channelID)
				
			}

		} 

	}

}		

daMixer.mOnObjectWillBeRemoved=function(activeDevice,activeMapping,objectID){

	var channelIDIndex=channelsIds.indexOf(objectID)

	if(channelIDIndex!=-1){
	
		channelsIds.splice(channelIDIndex,1)
		channelsUniqueNames.splice(channelIDIndex,1)
	
	}

}

daFQC.mOnObjectChange=function(activeDevice,activeMapping,objectID){

	 if(objectID==daFQC.getBaseObjectID(activeMapping) && paramIsLocked==false){

		var newFocusedObjectID=-1
		var pluginPathTag=4
		var newPluginPath=daMixer.getParameterDisplayValue(activeMapping,objectID,pluginPathTag)
		
		if(newPluginPath!=currentPluginPath){
			
			currentPluginPath=newPluginPath
			
			if(currentPluginPath.length==0){
				
				focusedObjectID=-1
				focusedParamTag=-1

				return 

			}

			var channelPath=currentPluginPath.split("\\").slice(0,3).join("\\")

			var indexOfChannel=channelsUniqueNames.indexOf(channelPath)
			
			if(indexOfChannel!=-1){
			
				var channelID=channelsIds[indexOfChannel]
				
				var instrumentPrefix=channelPath+"\\Slot\\"
				var insertsPrefix=channelPath+"\\Inserts\\Slot"
				var start,end,slotIndex

				if(currentPluginPath.indexOf(instrumentPrefix)==0){

					var instrumentMainSlotID=daMixer.getChildObjectID(activeMapping,channelID,9)
				
					if(instrumentMainSlotID>-1){

						if(daMixer.getNumberOfChildObjects(activeMapping,instrumentMainSlotID)>0){

							newFocusedObjectID=daMixer.getChildObjectID(activeMapping,instrumentMainSlotID,0)

						}
						
					}
					

				} else {
					
					start=currentPluginPath.indexOf(insertsPrefix)

					if(start!==-1){

						start+=insertsPrefix.length
						end=currentPluginPath.indexOf("\\",start)
						slotIndex= end>start ? parseInt(currentPluginPath.substring(start,end),10) : 1
						
						if(slotIndex>0){

							var insertsID=daMixer.getChildObjectID(activeMapping,channelID,3)
							var slotID=daMixer.getChildObjectID(activeMapping,insertsID,slotIndex-1)
							newFocusedObjectID=daMixer.getChildObjectID(activeMapping,slotID,0)

						}

					}
				
				}

				if(newFocusedObjectID==-1){
					
					focusedParamTag=-1
					focusedObjectID=-1

				} else if(newFocusedObjectID!=focusedObjectID){
					focusedParamTag=-1
					focusedObjectID=newFocusedObjectID
					activateParams(activeMapping)
					
				}

			} else {

				console.log("Channel not found. This shouldn't happen when project is not empty.")
			
				focusedObjectID=-1
				focusedParamTag=-1
			
			}
		
		}
	 
	}
	
}

function activateParams(activeMapping){
	
	var numOfParams=daMixer.getNumberOfParameters(activeMapping,focusedObjectID)
	
	if(numOfParams>0){
	
		for(var i=0;i<numOfParams;i++){
	
			var tmpParamTag=daMixer.getParameterTagByIndex(activeMapping,focusedObjectID,i)
			var tmpParamState=daMixer.getParameterEditLockState(activeMapping,focusedObjectID,tmpParamTag)
	
		}
	
	}

}

daMixer.mOnParameterChange=function(activeDevice,activeMapping,objectID,paramTag){

	if(objectID==focusedObjectID && paramIsLocked==false){
		
		if(paramTag!=focusedParamTag && paramTag!=-1 && aiDisabled==1){
		
			var currentParamValue=daMixer.getParameterProcessValue(activeMapping,objectID,paramTag)
			knobFinal.mSurfaceValue.setProcessValue(activeDevice,currentParamValue)

		}

		focusedParamTag=paramTag
		recallParametersMap[objectID]=paramTag

	}

}

function createVar(varName,channel,cc,knobType){

	var control=surface.makeCustomValueVariable(varName)
	control.mMidiBinding
		.setInputPort(midiInput)
		.bindToControlChange(channel,cc)

	var resIndex=varName.indexOf("Fine")!=-1 ? 1 : 0

	var isButton=varName.indexOf("button")!=-1

	var buttonGateOrToggle=varName.indexOf("Gate")!=-1 ? 0 : varName.indexOf("Toggle")!=-1 ? 1 : -1

	var incDec=varName.indexOf("Dec")!=-1 ? -1 : 1

	var isLock=varName.indexOf("Lock")

	var isRecall=varName.indexOf("Recall")

	control.mOnProcessValueChange=function(activeDevice,value,diff){
		
		switch (true){

			case this.isRecall!=-1:

				recallObjectParameter=!recallObjectParameter
				var recallPlain=recallObjectParameter ? 1 : 0
				ledRecall.mSurfaceValue.setProcessValue(activeDevice,recallPlain)

				return

				break 

			case this.isLock!=-1:

				if(value==1 && ((focusedObjectID>-1 && focusedParamTag>-1) || aiDisabled==0) && paramIsLocked==false){

					paramIsLocked=true
					ledLocked.mSurfaceValue.setProcessValue(activeDevice,1)
			
				} else {

					paramIsLocked=false 
					ledLocked.mSurfaceValue.setProcessValue(activeDevice,0)
			
				}
			
				return
				
				break 

			case this.buttonGateOrToggle==0:

				//Gate
				paramResIndex=value
				ledFine.mSurfaceValue.setProcessValue(activeDevice,value)

				break 

			case this.buttonGateOrToggle==1:
				
				//Toggle
				if(value==1){

					paramResIndex=1-paramResIndex
					ledFine.mSurfaceValue.setProcessValue(activeDevice,paramResIndex)

				}
		
				break 

			default:

				//Altering the parameter value here
				if(aiDisabled==0){

					var value127=Math.round(value*127)
				
					var kt=this.knobType
				
					var tmpIncDec=(this.isButton ? this.incDec : (kt==0 ? (value==0||diff<0?-1:1) : kt==1 ? (value127-64) : kt==2 ? (value127<64?value127:value127-128) : kt==3 ? (value==0?0:(value127<64?value127:value127-128)) : 1))

					if(tmpIncDec==0) return 

					var currentParamVal=knobFinal.mSurfaceValue.getProcessValue(activeDevice)
					
					var newParamVal=Math.max(0,Math.min(currentParamVal+tmpIncDec*paramResolutions[this.resIndex>0 ? this.resIndex : paramResIndex],1))

					if(newParamVal!=currentParamVal){

						knobFinal.mSurfaceValue.setProcessValue(activeDevice,newParamVal)

					}

					//An alternative and safer approach sometime. However, this doesn't take advantage of the "Fine" option					
					
					/* if(tmpIncDec==1){
					
						aiHostValue.increment(currentMapping)
						
					
					} else if (tmpIncDec==-1){
					
						aiHostValue.decrement(currentMapping)
					
					}*/

					//End alternative

					return 
					
				}

				if(focusedObjectID==-1) return
			
				if(focusedParamTag==-1){
				
					if(recallObjectParameter==false) return 
				
					var previousStoredParam=recallParametersMap[focusedObjectID.toString()]
				
					if(previousStoredParam!==undefined){
				
						focusedParamTag=previousStoredParam
				
					} else {
				
						return 
				
					}
				
				}
				
				var value127=Math.round(value*127)
				
				var kt=this.knobType
				
				var tmpIncDec=(this.isButton ? this.incDec : (kt==0 ? (value==0||diff<0?-1:1) : kt==1 ? (value127-64) : kt==2 ? (value127<64?value127:value127-128) : kt==3 ? (value==0?0:(value127<64?value127:value127-128)) : 1))
				
				if(tmpIncDec==0) return 

				var currentParamVal=daMixer.getParameterProcessValue(currentMapping,focusedObjectID,focusedParamTag)
				var paramType=daMixer.getParameterProcessValueType(currentMapping,focusedObjectID,focusedParamTag)
				
				if(paramType=="discrete"){
				
					//We can always use the mouse wheel for this, resolution is of no importance here, still, I had to implement this part as well

					//Doesn't work in all VSTs unfortunately
					var stepCount=daMixer.convertParameterProcessValueToPlain(currentMapping,focusedObjectID,focusedParamTag,1)
					
					if(stepCount>1){

						currentParamVal+=tmpIncDec>0 ? 1/stepCount : -1/stepCount
						setNewParameterValue(activeDevice,currentParamVal)

					} else {

						//Have to get nasty :(
						var currentParamDisplayValue=daMixer.getParameterDisplayValue(currentMapping,focusedObjectID,focusedParamTag)
						
						var flagNoDisplayValueChanged=true 

						while(flagNoDisplayValueChanged){

							currentParamVal+=tmpIncDec*paramResolutions[0]
							setNewParameterValue(activeDevice,currentParamVal)

							var newParamDisplayValue=daMixer.getParameterDisplayValue(currentMapping,focusedObjectID,focusedParamTag)
							
							flagNoDisplayValueChanged=newParamDisplayValue==currentParamDisplayValue && currentParamVal>0 && currentParamVal<1
					
						}
					
					}
					
				} else {
				
					currentParamVal+=tmpIncDec*paramResolutions[this.resIndex>0 ? this.resIndex : paramResIndex]

					setNewParameterValue(activeDevice,currentParamVal)
				
				}
				
				break 

		}

	}.bind({isRecall,isLock,resIndex,incDec,isButton,buttonGateOrToggle,knobType})

	return control 

}

function setNewParameterValue(activeDevice,value){

	var newValue=Math.max(0,Math.min(1,value))

	var previousLockState=daMixer.getParameterEditLockState(currentMapping,focusedObjectID,focusedParamTag)

	daMixer.setParameterEditLockState(currentMapping,focusedObjectID,focusedParamTag,true)
	daMixer.setParameterProcessValue(currentMapping,focusedObjectID,focusedParamTag,newValue)

	daMixer.setParameterEditLockState(currentMapping,focusedObjectID,focusedParamTag,previousLockState)
	
	knobFinal.mSurfaceValue.setProcessValue(activeDevice,newValue)
	
}

A small demo:

Great work!

I’ve looked at the script, and while I was able to find the MIDI port, I can’t find the CCs used; could you please point me to the related script lines?

Sure, they are at the top of the script:

var ccChannel=0 //The MIDI Channel of the CC messages

var ccKnobMain=1 //This alters the focused parameter in the first resolution step (default: 1/127)
var knobMainType=knobTypes.absolute //Defining the type of our main knob

var ccKnobFine=2 //Altering using the "Fine" resolution step (default: 1/1023)
var knobFineType=knobTypes.absolute //Defining the type of our "Fine" knob

var ccButtonLock=3 //Used to toggle locking the currently focused parameter. If Lock is on, we continue to alter this parameter, no matter where the focus gets

var ccButtonGateResolution=4 //Used to "Gate" to the "Fine" Resolution. While holding this button, the "Fine" Resolution will be used
var ccButtonToggleResolution=5 //Toggling the Resolution

var ccButtonDecrease=6 //Decrease the parameter value. If "Fine" is on, the decrease will comply to the "Fine" Resolution setting
var ccButtonIncrease=7 //Increase the parameter value. If "Fine" is on, the decrease will comply to the "Fine" Resolution setting

var ccButtonDecreaseFine=8 //Decrease the parameter value in "Fine" Resolution
var ccButtonIncreaseFine=9 //Increase the parameter value in "Fine" Resolution

var ccButtonRecallParameter=10 //This one is interesting. If toggled to "On", the script will remember the last parameter we touched (clicked) on any plugin Window, and recall it upon reshowing it,so that we can continue altering its value without even focusing the parameter

For example, we see a variable named ccKnobMain=1. This means that I set the main knob to CC 1. The channel is set to 0.

Thanks a lot, I did found the channel number, but the way the CC number was indicated, with no leading zero, made me think that I had to look elsewhere :sweat_smile:. I’ve tried the script, sending all of the Control Change and it works great, the only thing I’ve noticed is that with CC1, the coarse value, I cannot control the whole range of the parameter, i.e. if I move the control all the way back to zero the parameter will not go to its lowest value, like if the relationship between the parameter and the CC message is not 1:1. I’m using a fader on my controller to send CC1 with values from 0 to 127 and, if I understand correctly, this correspond to the knob type of absolute, that should be the value used in the script

This is the issue. When we turn a knob, and it gets to 0, it will continue sending 0, when it’s in reality a relative knob mapped to absolute values. In the case of a fader, it doesn’t keep sending 0s. I can workaround this, by adding a “scaled” resolving in the case of the fader. Totally doable, but I cannot currently work on it, perhaps later in the day, or tomorrow.

Thanks a lot!
On my controller I could use a knob instead of a fader, but since it’s not a relative value knob, I think it would not solve the problem. Anyway, I’m out of the studio the whole day, so I can’t try this before tonight or tomorrow morning

I think I have found how to make it work! I used two encoders on my controller for CC 1 and 2, instead of faders, and I put them in relative mode. I then changed the script so the two knobs in question are complement2s type, and now it seems to work properly.
It seemed strange to me that on the script the knob type was absolute; I’m wondering how it could work with absolute type knobs

Good.

Here, I have some midi keyboards connected to some hardware synths which demand absolute values. So, I have a midi translator, transforming the relative values to absolute. Since the script presented here, was never intended for direct use, but rather as a demo, and since I needed to try it first, I put the absolute mode inside of it, in order to not alter a single bit of my setup. In this setup with absolute mode, when we arrive from the right to 0, the controller keeps sending a 0 upon turning, and when we’re at the right side, it will keep sending a 127. This is how it works.

Oh, I see, now it’s clear!