-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
add support for loading controller scripts as JS modules #2868
Changes from 26 commits
4cf5dd0
40246b5
6636d0e
1bfdaf4
537f2a1
60590e8
a988523
949b11b
019bf82
a1ac571
2a9aa02
dddaab6
d9464c0
071b306
e1e8145
dddccd5
6ddc7bb
ef745f1
e4a3c58
6f59d5a
8182c77
20f1485
328611e
868fd04
80f18ec
abe2624
3ef56de
ecc740d
f963cc3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
/** | ||
* MidiDispatcher routes incoming MIDI messages to registered callbacks. Register callbacks with | ||
* the setInputCallback method in your controller script module's init function and call | ||
* MidiDispatcher's receiveMidiData method in your controller script module's receiveData function. | ||
* | ||
* This class is not designed to handle System Exclusive or MIDI Clock messages. For those, implement logic | ||
* specific to your controller in the controller module's receiveData function before calling | ||
* MidiDispatcher.receiveData. | ||
*/ | ||
|
||
// MIDI messages starting with 0xC (program change) or 0xD (aftertouch) messages are only | ||
// two bytes long and distinguished by their first byte. | ||
// https://www.midi.org/specifications-old/item/table-2-expanded-messages-list-status-bytes | ||
const identifiedByFirstByte = (midiBytes) => { | ||
return (midiBytes[0] & 0xF0) == 0xC0 || (midiBytes[0] & 0xF0) === 0xD0; | ||
} | ||
|
||
// JavaScript is broken and believes [1,2] === [1,2] is false, so | ||
// this function turns an array of MIDI bytes into a unique hashable key. | ||
// This function does not work for system exclusive messages; that is out of scope. | ||
const hashMidiBytes = (midiBytes) => { | ||
if (identifiedByFirstByte(midiBytes)) { | ||
return midiBytes[0]; | ||
} | ||
return midiBytes[0] + (midiBytes[1] << 8); | ||
} | ||
|
||
export class MidiDispatcher { | ||
/** | ||
* @param {bool} noteOff - When setting the callback for a Note On message, also map the corresponding Note Off | ||
Be-ing marked this conversation as resolved.
Show resolved
Hide resolved
|
||
* message to the same callback. Defaults to true. | ||
*/ | ||
constructor(noteOff) { | ||
if (noteOff === undefined) { | ||
noteOff = true; | ||
} | ||
this.noteOff = noteOff; | ||
this.inputMap = new Map(); | ||
Be-ing marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
/** | ||
* Set the callback for an incoming MIDI message. | ||
* @param {Array} midiBytes - Array of numbers indicating the beginning of the MIDI messages. In most cases, | ||
Be-ing marked this conversation as resolved.
Show resolved
Hide resolved
|
||
* this should be the first two bytes of the MIDI messages, for example [0x93, 0x27] | ||
* | ||
* Program change (starting with 0xC) and aftertouch (starting with 0xD) messages are distinguished by only | ||
* their first byte, so in those cases the Array should only have one number. | ||
* | ||
* @param {MidiInputHandler} callback - The callback that will be called by the handleMidiInput method | ||
* when a MIDI message matching midiBytes is received from the controller. | ||
* | ||
* @callback MidiInputHandler | ||
* @param {Array} data - incoming MIDI data | ||
Be-ing marked this conversation as resolved.
Show resolved
Hide resolved
|
||
* @param {number} timestamp - The timestamp that Mixxx received the MIDI data at (milliseconds) | ||
Be-ing marked this conversation as resolved.
Show resolved
Hide resolved
|
||
*/ | ||
Be-ing marked this conversation as resolved.
Show resolved
Hide resolved
|
||
setInputCallback(midiBytes, callback) { | ||
if (!Array.isArray(midiBytes)) { | ||
throw new Error('MidiDispatcher.setInputCallback midiBytes must be an Array, received ' + midiBytes); | ||
} | ||
if (typeof midiBytes[0] !== 'number') { | ||
throw new Error('MidiDispatcher.setInputCallback midiBytes must be an Array of numbers, received ' + midiBytes); | ||
} | ||
if (typeof callback !== 'function') { | ||
throw new Error('MidiDispatcher.setInputCallback callback must be a function, received ' + callback); | ||
} | ||
const key = hashMidiBytes(midiBytes); | ||
this.inputMap.set(key, callback); | ||
// If passed a Note On message, also map the corresponding Note Off | ||
// message to the same callback. | ||
if (this.noteOff === true && ((midiBytes[0] & 0xF0) === 0x90)) { | ||
Holzhaus marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const noteOffBytes = [midiBytes[0] - 0x10, midiBytes[1]]; | ||
const noteOffKey = hashMidiBytes(noteOffBytes); | ||
this.inputMap.set(noteOffKey, callback); | ||
} | ||
} | ||
/** | ||
* Receive incoming MIDI data from a controller and execute the callback registered to that | ||
* MIDI message. | ||
* @param {Array} data - The incoming MIDI data. | ||
Be-ing marked this conversation as resolved.
Show resolved
Hide resolved
|
||
* @param {number} timestamp - The timestamp that Mixxx received the MIDI data at (milliseconds) | ||
*/ | ||
handleMidiInput(data, timestamp) { | ||
const key = hashMidiBytes(data); | ||
const callback = this.inputMap.get(key); | ||
// This assumes a script has not modified this.inputMap directly without this.setInputCallback. | ||
if (callback !== undefined && callback !== null) { | ||
callback(data, timestamp); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -73,6 +73,10 @@ bool Controller::applyPreset(bool initializeScripts) { | |
if (success && initializeScripts) { | ||
m_pEngine->initializeScripts(scriptFiles); | ||
} | ||
|
||
if (initializeScripts) { | ||
m_pEngine->loadModule(pPreset->moduleFileInfo()); | ||
Be-ing marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
return success; | ||
} | ||
|
||
|
@@ -143,4 +147,6 @@ void Controller::receive(const QByteArray data, mixxx::Duration timestamp) { | |
QJSValue incomingDataFunction = m_pEngine->wrapFunctionCode(function, 2); | ||
m_pEngine->executeFunction(incomingDataFunction, data); | ||
} | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should deprecate the use of ".incomingData". This will be a nice performance benefit. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That is what I did, but below you are saying the opposite. |
||
m_pEngine->handleInput(data, timestamp); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here is the place where the dispatcher should directly call the js callbacks. The mapping can still optional provide an "incomingData" function to perform difficult tasks. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, the dispatcher is JS. There are no plans to change this. |
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -57,7 +57,7 @@ void PresetInfoEnumerator::loadSupportedPresets() { | |
m_bulkPresets.clear(); | ||
|
||
for (const QString& dirPath : m_controllerDirPaths) { | ||
QDirIterator it(dirPath); | ||
QDirIterator it(dirPath, QDirIterator::Subdirectories); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This can is unrelated, right? I think enabling support for controller scripts in subdirectories is a different matter. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It was very simple to implement and I don't think there's any reason to remove it now. |
||
while (it.hasNext()) { | ||
it.next(); | ||
const QString path = it.filePath(); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -149,6 +149,10 @@ void ControllerEngine::gracefulShutdown() { | |
qDebug() << "Invoking shutdown() hook in scripts"; | ||
callFunctionOnObjects(m_scriptFunctionPrefixes, "shutdown"); | ||
|
||
if (m_shutdownFunction.isCallable()) { | ||
executeFunction(m_shutdownFunction, QJSValueList{}); | ||
} | ||
|
||
// Prevents leaving decks in an unstable state | ||
// if the controller is shut down while scratching | ||
QHashIterator<int, int> i(m_scratchTimers); | ||
|
@@ -190,6 +194,8 @@ void ControllerEngine::initializeScriptEngine() { | |
// Create the Script Engine | ||
m_pScriptEngine = new QJSEngine(this); | ||
|
||
m_pScriptEngine->installExtensions(QJSEngine::ConsoleExtension); | ||
|
||
// Make this ControllerEngine instance available to scripts as 'engine'. | ||
QJSValue engineGlobalObject = m_pScriptEngine->globalObject(); | ||
ControllerEngineJSProxy* proxy = new ControllerEngineJSProxy(this); | ||
|
@@ -223,6 +229,53 @@ void ControllerEngine::uninitializeScriptEngine() { | |
} | ||
} | ||
|
||
void ControllerEngine::loadModule(QFileInfo moduleFileInfo) { | ||
#if QT_VERSION >= QT_VERSION_CHECK(5, 12, 0) | ||
m_moduleFileInfo = moduleFileInfo; | ||
|
||
QJSValue mod = m_pScriptEngine->importModule(moduleFileInfo.absoluteFilePath()); | ||
if (mod.isError()) { | ||
showScriptExceptionDialog(mod); | ||
return; | ||
} | ||
|
||
connect(&m_scriptWatcher, | ||
&QFileSystemWatcher::fileChanged, | ||
this, | ||
&ControllerEngine::scriptHasChanged); | ||
m_scriptWatcher.addPath(moduleFileInfo.absoluteFilePath()); | ||
|
||
QJSValue initFunction = mod.property("init"); | ||
executeFunction(initFunction, QJSValueList{}); | ||
|
||
QJSValue handleInputFunction = mod.property("handleInput"); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why not stick with the term "incomingData"? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Because this is a completely new system. |
||
if (handleInputFunction.isCallable()) { | ||
m_handleInputFunction = handleInputFunction; | ||
} else { | ||
scriptErrorDialog( | ||
"Controller JavaScript module exports no handleInput function.", | ||
QStringLiteral("handleInput"), | ||
true); | ||
} | ||
|
||
QJSValue shutdownFunction = mod.property("shutdown"); | ||
if (shutdownFunction.isCallable()) { | ||
m_shutdownFunction = shutdownFunction; | ||
} else { | ||
qDebug() << "Module exports no shutdown function."; | ||
} | ||
#endif | ||
} | ||
|
||
void ControllerEngine::handleInput(QByteArray data, mixxx::Duration timestamp) { | ||
if (m_handleInputFunction.isCallable()) { | ||
QJSValueList args; | ||
args << byteArrayToScriptValue(data); | ||
args << timestamp.toDoubleMillis(); | ||
executeFunction(m_handleInputFunction, args); | ||
} | ||
} | ||
|
||
bool ControllerEngine::loadScriptFiles(const QList<ControllerPreset::ScriptFileInfo>& scripts) { | ||
bool scriptsEvaluatedCorrectly = true; | ||
for (const auto& script : scripts) { | ||
|
@@ -263,6 +316,7 @@ void ControllerEngine::reloadScripts() { | |
|
||
qDebug() << "Re-initializing scripts"; | ||
initializeScripts(m_lastScriptFiles); | ||
loadModule(m_moduleFileInfo); | ||
} | ||
|
||
void ControllerEngine::initializeScripts(const QList<ControllerPreset::ScriptFileInfo>& scripts) { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you document the class and it’s members using JSdoc comments?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please review the documentation: 98a3d82