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: