diff --git a/readme.md b/readme.md index 2cd2ed9..9bcbc8d 100644 --- a/readme.md +++ b/readme.md @@ -57,6 +57,31 @@ On A and M series keyboards, pulling the 4-d encoder right and left moves throug Use 4-d encoder down and up to switch plugins. To return to normal operation, press the 4-d encoder again. +## Configurable Mapping of Non-NKS FX Parameters +By default, all FX parameters are mapped to pages and knobs on the Kontrol keyboard. +For plugins with many parameters, this might not be efficient. +ReaKontrol enables you to specify how parameters are mapped to Kontrol knobs for specific plugins using map files. +ReaKontrol will load these maps from `reaKontrol/fxMaps/plugin name.rkfm` inside the REAPER resource folder. +Slash, backslash and colon are removed from the plugin name. +For example, on Windows, you might have `%appdata%\REAPER\reaKontrol\fxMaps\VST ReaComp (Cockos).rkfm`. +The file format is as follows: + +``` +# This is a comment. +ReaComp: # This is optional and overrides the FX name reported to users. +0 # Parameter 0 will be mapped to the first knob on the first page. +2 # Parameter 2 will be mapped to the second knob. +4 Pre comp # Overrides the name reported to the user for this third knob. +--- # Page break. The remaining knobs on this page will be unassigned. +[mix] # This is a section name for the following knobs. +6 # Parameter 6 will be mapped to the first knob on the second page. +``` + +ReaKontrol can help you create these maps. +First, select the FX you want to work with using your Kontrol keyboard. +Note that the first FX (if any) will be automatically selected when you navigate to a track. +Then, run the "ReaKontrol: Generate map file for selected FX" action and it will generate the appropriate map file with all the parameter numbers and their names as comments. + ## Reconnecting ReaKontrol will connect to a Kontrol keyboard when REAPER starts. If the keyboard isn't turned on or connected when REAPER starts, ReaKontrol is not currently able to detect when the keyboard is connected. diff --git a/src/fxMap.cpp b/src/fxMap.cpp new file mode 100644 index 0000000..ae946e7 --- /dev/null +++ b/src/fxMap.cpp @@ -0,0 +1,256 @@ +/* + * ReaKontrol + * FX Map code + * Author: James Teh + * Copyright 2026 James Teh + * License: GNU General Public License version 2.0 + */ + +#ifdef _WIN32 +#include +#endif + +#include +#include +#include +#include +#include "fxMap.h" +#include "reaKontrol.h" + +static const std::regex RE_LINE( + // Ignore any space at the start of a line. + "^\\s*" + "(?:" + // Group 1: the line may be the map name, ending with a colon. + "([^#]+):" + // Groups 2 and 3: or a parameter number, optionally followed by space and + // a name. + "|(\\d+)(?:\\s+([^#]+))?" + // Group 4: or a page break indicator. + "|(---)" + // Group 5: or a section name in square brackets. + "|\\[([^#]+)\\]" + ")?" + // This may be followed by optional space and an optional comment starting + // with "#". + "\\s*(?:#.*)?$" +); + +// The generateMapFileForSelectedFx action can't access the FxMap instance, +// so we cache the last selected FX here. +static MediaTrack* lastTrack = nullptr; +static int lastFx = -1; + +static std::filesystem::path getFxMapDir() { + std::filesystem::path path(std::u8string_view((char8_t*)GetResourcePath())); + path /= "reaKontrol"; + path /= "fxMaps"; + return path; +} + +static std::filesystem::path getFxMapFileName(MediaTrack* track, int fx) { + char name[100] = ""; + TrackFX_GetFXName(track, fx, name, sizeof(name)); + if (!name[0]) { + // This will happen when there are no FX on this track. + return ""; + } + std::filesystem::path path = getFxMapDir(); + path /= ""; + for (char* c = name; *c; ++c) { + if (*c == '/' || *c == '\\' || *c == ':') { + continue; + } + path += (char8_t)*c; + } + path += ".rkfm"; + return path; +} + +FxMap::FxMap(MediaTrack* track, int fx) : _track(track), _fx(fx) { + lastTrack = track; + lastFx = fx; + const std::filesystem::path path = getFxMapFileName(track, fx); + if (path.empty()) { + return; + } + std::ifstream input(path); + if (!input) { + log("no FX map " << path); + return; + } + log("loading FX map " << path); + std::string line; + while (std::getline(input, line)) { + std::smatch m; + std::regex_search(line, m, RE_LINE); + if (m.empty()) { + log("invalid FX map line: " << line); + continue; + } + if (!m.str(1).empty()) { + if (!this->_mapName.empty()) { + log("map name specified more than once, ignoring: " << line); + continue; + } + this->_mapName = m.str(1); + log("map name: " << this->_mapName); + continue; + } + const std::string numStr = m.str(2); + if (!numStr.empty()) { + const int rp = std::atoi(numStr.c_str()); + const int mp = this->_reaperParams.size(); + this->_reaperParams.push_back(rp); + this->_mapParams.insert({rp, mp}); + const std::string paramName = m.str(3); + if (!paramName.empty()) { + this->_paramNames.insert({mp, paramName}); + } + continue; + } + if (!m.str(4).empty()) { + // A page break has been requested. Any remaining slots on this page + // should be empty. + while (this->_reaperParams.size() % BANK_NUM_SLOTS != 0) { + this->_reaperParams.push_back(-1); + } + continue; + } + const std::string section = m.str(5); + if (!section.empty()) { + this->_sections.insert({this->_reaperParams.size(), section}); + } + } + log("loaded " << this->_mapParams.size() << " params from FX map"); +} + +std::string FxMap::getMapName() const { + if (this->_mapName.empty()) { + char name[100]; + TrackFX_GetFXName(this->_track, this->_fx, name, sizeof(name)); + return name; + } + return this->_mapName; +} + +int FxMap::getParamCount() const { + if (this->_reaperParams.empty()) { + return TrackFX_GetNumParams(this->_track, this->_fx); + } + return this->_reaperParams.size(); +} + +int FxMap::getReaperParam(int mapParam) const { + if (this->_reaperParams.empty()) { + return mapParam; + } + return this->_reaperParams[mapParam]; +} + +int FxMap::getMapParam(int reaperParam) const { + if (this->_reaperParams.empty()) { + return reaperParam; + } + auto it = this->_mapParams.find(reaperParam); + if (it == this->_mapParams.end()) { + return -1; + } + return it->second; +} + +std::string FxMap::getParamName(int mapParam) const { + auto it = this->_paramNames.find(mapParam); + if (it == this->_paramNames.end()) { + char name[100]; + const int rp = this->getReaperParam(mapParam); + TrackFX_GetParamName(this->_track, this->_fx, rp, name, sizeof(name)); + return name; + } + return it->second; +} + +std::string FxMap::getSection(int mapParam) const { + auto it = this->_sections.find(mapParam); + if (it == this->_sections.end()) { + return ""; + } + return it->second; +} + +std::string FxMap::getSectionsForPage(int mapParam) const { + std::ostringstream s; + int bankEnd = mapParam + BANK_NUM_SLOTS; + if (bankEnd > this->_reaperParams.size()) { + bankEnd = this->_reaperParams.size(); + } + for (; mapParam < bankEnd; ++mapParam) { + std::string section = this->getSection(mapParam); + if (!section.empty()) { + if (s.tellp() > 0) { + s << ", "; + } + s << section; + } + } + return s.str(); +} + +std::string FxMap::getMapNameFor(MediaTrack* track, int fx) { + auto getOrigName = [&]() -> std::string { + char name[100] = ""; + TrackFX_GetFXName(track, fx, name, sizeof(name)); + return name; + }; + const std::filesystem::path path = getFxMapFileName(track, fx); + if (path.empty()) { + return getOrigName(); + } + std::ifstream input(path); + if (!input) { + return getOrigName(); + } + std::string line; + while (std::getline(input, line)) { + std::smatch m; + std::regex_search(line, m, RE_LINE); + if (m.empty()) { + continue; + } + if (!m.str(1).empty()) { + return m.str(1); + } + if (!m.str(2).empty() || !m.str(4).empty() || !m.str(5).empty()) { + // The map name must be the first non-comment, non-blank line. If we hit + // anything else, there's no map name, so don't process any further. + break; + } + } + return getOrigName(); +} + +void FxMap::generateMapFileForSelectedFx() { + const std::filesystem::path fn = getFxMapFileName(lastTrack, lastFx); + if (fn.empty()) { + // No selected FX. + return; + } + const std::filesystem::path dir = getFxMapDir(); + std::filesystem::create_directories(dir); + std::ofstream output(fn); + if (!output) { + return; + } + const int count = TrackFX_GetNumParams(lastTrack, lastFx); + for (int p = 0; p < count; ++p) { + char name[100]; + TrackFX_GetParamName(lastTrack, lastFx, p, name, sizeof(name)); + output << p << " # " << name << std::endl; + } + output.close(); + // Locate the file in Explorer/Finder. + std::u8string params(u8"/select,"); + params += fn.u8string(); + ShellExecute(nullptr, "open", "explorer.exe", (const char*)params.c_str(), + nullptr, SW_SHOW); +} diff --git a/src/fxMap.h b/src/fxMap.h new file mode 100644 index 0000000..ce09872 --- /dev/null +++ b/src/fxMap.h @@ -0,0 +1,40 @@ +/* + * ReaKontrol + * FX Map header + * Author: James Teh + * Copyright 2026 James Teh + * License: GNU General Public License version 2.0 + */ + +#pragma once + +#include +#include +#include + +class MediaTrack; + +class FxMap { + public: + FxMap() = default; + FxMap(MediaTrack* track, int fx); + std::string getMapName() const; + int getParamCount() const; + int getReaperParam(int mapParam) const; + int getMapParam(int reaperParam) const; + std::string getParamName(int mapParam) const; + std::string getSection(int mapParam) const; + std::string getSectionsForPage(int mapParam) const; + + static std::string getMapNameFor(MediaTrack* track, int fx); + static void generateMapFileForSelectedFx(); + + private: + std::string _mapName; + MediaTrack* _track = nullptr; + int _fx = -1; + std::vector _reaperParams; + std::map _paramNames; + std::map _mapParams; + std::map _sections; +}; diff --git a/src/main.cpp b/src/main.cpp index e012653..4154d8c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -18,6 +18,7 @@ #include "swell.h" #endif #define REAPERAPI_IMPLEMENT +#include "fxMap.h" #include "reaKontrol.h" using namespace std; @@ -204,6 +205,7 @@ void disconnect() { } int CMD_RECONNECT = 0; +int CMD_GENERATE_FX_MAP = 0; bool handleCommand(KbdSectionInfo* section, int command, int val, int valHw, int relMode, HWND hwnd @@ -213,6 +215,10 @@ bool handleCommand(KbdSectionInfo* section, int command, int val, int valHw, connect(); return true; } + if (command == CMD_GENERATE_FX_MAP) { + FxMap::generateMapFileForSelectedFx(); + return true; + } return false; } @@ -235,6 +241,9 @@ REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(REAPER_PLUGIN_HINSTANCE hI custom_action_register_t action = {MAIN_SECTION, "REAKONTROL_RECONNECT", "ReaKontrol: Reconnect"}; CMD_RECONNECT = rec->Register("custom_action", &action); + action = {MAIN_SECTION, "REAKONTROL_GENFXMAP", + "ReaKontrol: Generate map file for selected FX"}; + CMD_GENERATE_FX_MAP = rec->Register("custom_action", &action); rec->Register("hookcommand2", (void*)handleCommand); rec->Register("timer", (void*)delayedInit); return 1; diff --git a/src/niMidi.cpp b/src/niMidi.cpp index 25a6050..379e8f2 100644 --- a/src/niMidi.cpp +++ b/src/niMidi.cpp @@ -13,6 +13,7 @@ #include #include #include +#include "fxMap.h" #include "reaKontrol.h" using namespace std; @@ -21,7 +22,6 @@ const unsigned char MIDI_CC = 0xBF; const unsigned char MIDI_SYSEX_BEGIN[] = { 0xF0, 0x00, 0x21, 0x09, 0x00, 0x00, 0x44, 0x43, 0x01, 0x00}; const unsigned char MIDI_SYSEX_END = 0xF7; -const int BANK_NUM_SLOTS = 8; const unsigned char CMD_HELLO = 0x01; const unsigned char CMD_GOODBYE = 0x02; @@ -291,11 +291,12 @@ class NiMidiSurface: public BaseSurface { return 0; } int param = *(int*)parm2 & 0xFFFF; - if (param < this->_fxBankStart || param >= this->_fxBankStart + BANK_NUM_SLOTS) { + const int mp = this->_fxMap.getMapParam(param); + if (mp < this->_fxBankStart || mp >= this->_fxBankStart + BANK_NUM_SLOTS) { return 0; } double normVal = *(double*)parm3; - const int numInBank = param - this->_fxBankStart; + const int numInBank = mp - this->_fxBankStart; this->_fxParamValueChanged(param, numInBank, normVal); } else if (call == CSURF_EXT_SETFXCHANGE) { // FX were added, removed or reordered. @@ -577,6 +578,7 @@ class NiMidiSurface: public BaseSurface { int _fxBankStart = 0; int _lastChangedFxParam = -1; double _lastChangedFxParamValue = 0; + FxMap _fxMap; void _onTrackBankChange() { if (this->_isUsingMixerForFx()) { @@ -730,14 +732,13 @@ class NiMidiSurface: public BaseSurface { auto addContainer = [&s, this](int parentFx, auto&& addContainer) -> void { // Containers are represented using JSON. s << "{\"n\":\""; - char name[100] = ""; - TrackFX_GetFXName(this->_lastSelectedTrack, parentFx, name, sizeof(name)); + const string name = FxMap::getMapNameFor(this->_lastSelectedTrack, parentFx); // Escape any quote characters in the name. - for (char* n = name; *n; ++n) { - if (*n == '"') { + for (char n : name) { + if (n == '"') { s << "\\"; } - s << *n; + s << n; } s << "\",\"c\":["; for (int c = 0; ; ++c) { @@ -762,22 +763,19 @@ class NiMidiSurface: public BaseSurface { // This is a container. addContainer(f, addContainer); } else { - char name[100] = ""; - TrackFX_GetFXName(this->_lastSelectedTrack, f, name, sizeof(name)); - s << name; + s << FxMap::getMapNameFor(this->_lastSelectedTrack, f); } } this->_sendSysex(CMD_PLUGIN_NAMES, 0, 0, s.str()); } void _fxChanged(bool shouldOutputOsaraMessage = true) { + this->_fxMap = FxMap(this->_lastSelectedTrack, this->_selectedFx); if (this->_protocolVersion >= 4) { this->_sendSelectPlugin(); } else if (shouldOutputOsaraMessage && osara_outputMessage) { - char name[100]; - TrackFX_GetFXName(this->_lastSelectedTrack, this->_selectedFx, name, - sizeof(name)); - osara_outputMessage(name); + osara_outputMessage(FxMap::getMapNameFor(this->_lastSelectedTrack, + this->_selectedFx).c_str()); } this->_fxBankStart = 0; this->_fxBankChanged(/* shouldOutputOsaraMessage */ false); @@ -822,8 +820,7 @@ class NiMidiSurface: public BaseSurface { } void _fxBankChanged(bool shouldOutputOsaraMessage = true) { - const int count = TrackFX_GetNumParams(this->_lastSelectedTrack, - this->_selectedFx); + const int count = this->_fxMap.getParamCount(); int numPages = count / BANK_NUM_SLOTS; if (count % BANK_NUM_SLOTS) { // numPages is the number of full pages. There is a final, partial page. @@ -845,6 +842,10 @@ class NiMidiSurface: public BaseSurface { if (shouldOutputOsaraMessage && osara_outputMessage) { ostringstream s; s << "page " << page + 1; + string sections = this->_fxMap.getSectionsForPage(this->_fxBankStart); + if (!sections.empty()) { + s << sections; + } osara_outputMessage(s.str().c_str()); } } else { @@ -853,10 +854,17 @@ class NiMidiSurface: public BaseSurface { // bankEnd is exclusive; i.e. 1 beyond the last parameter in the bank. const int bankEnd = min(this->_fxBankStart + BANK_NUM_SLOTS, count); int numInBank = 0; - for (int p = this->_fxBankStart; p < bankEnd; ++p, ++numInBank) { - char name[100] = ""; - TrackFX_GetParamName(this->_lastSelectedTrack, this->_selectedFx, p, name, - sizeof(name)); + for (int mp = this->_fxBankStart; mp < bankEnd; ++mp, ++numInBank) { + const int rp = this->_fxMap.getReaperParam(mp); + if (rp == -1) { + if (isMixer) { + this->_sendSysex(CMD_TRACK_AVAIL, 0, numInBank); + } else { + this->_sendSysex(CMD_PARAM_NAME, PARAM_VIS_UNIPOLAR, numInBank); + } + continue; + } + const string name = this->_fxMap.getParamName(mp); if (isMixer) { this->_sendSysex(CMD_TRACK_AVAIL, TRTYPE_UNSPEC, numInBank); this->_sendSysex(CMD_TRACK_NAME, 0, numInBank, name); @@ -865,13 +873,15 @@ class NiMidiSurface: public BaseSurface { this->_sendSysex(CMD_TRACK_MUTED, 0, numInBank); this->_sendSysex(CMD_TRACK_ARMED, 0, numInBank); } else { - const bool isToggle = this->_isFxParamToggle(p); + const bool isToggle = this->_isFxParamToggle(rp); this->_sendSysex(CMD_PARAM_NAME, isToggle ? PARAM_VIS_SWITCH : PARAM_VIS_UNIPOLAR, numInBank, name); + string section = this->_fxMap.getSection(mp); + this->_sendSysex(CMD_PARAM_SECTION, 0, numInBank, section); } double val = TrackFX_GetParamNormalized(this->_lastSelectedTrack, - this->_selectedFx, p); - this->_fxParamValueChanged(p, numInBank, val); + this->_selectedFx, rp); + this->_fxParamValueChanged(rp, numInBank, val); } // If there aren't sufficient parameters to fill the bank, clear the // remaining slots. @@ -902,8 +912,7 @@ class NiMidiSurface: public BaseSurface { } else { newBankStart -= BANK_NUM_SLOTS; } - const int count = TrackFX_GetNumParams(this->_lastSelectedTrack, - this->_selectedFx); + const int count = this->_fxMap.getParamCount(); if (newBankStart < 0 || newBankStart >= count) { return; } @@ -912,7 +921,11 @@ class NiMidiSurface: public BaseSurface { } void _changeFxParamValue(int numInBank, double change) { - int param = this->_fxBankStart + numInBank; + const int mp = this->_fxBankStart + numInBank; + const int param = this->_fxMap.getReaperParam(mp); + if (param == -1) { + return; + } double val ; if (param == this->_lastChangedFxParam) { // Some parameters snap to defined values when you set them. This means that diff --git a/src/reaKontrol.h b/src/reaKontrol.h index 055a791..cc92894 100644 --- a/src/reaKontrol.h +++ b/src/reaKontrol.h @@ -7,6 +7,9 @@ */ #pragma once +#define LOGGING + +#include #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_GetNumMIDIInputs @@ -62,20 +65,23 @@ #define REAPERAPI_WANT_projectconfig_var_getoffs #define REAPERAPI_WANT_projectconfig_var_addr #define REAPERAPI_WANT_plugin_getapi +#define REAPERAPI_WANT_GetResourcePath #include #include #ifdef LOGGING # include # define log(msg) { \ - ostringstream s; \ - s << "reaKontrol " << msg << endl; \ + std::ostringstream s; \ + s << "reaKontrol " << msg << std::endl; \ ShowConsoleMsg(s.str().c_str()); \ } #else # define log(msg) #endif +constexpr int BANK_NUM_SLOTS = 8; + const std::string getKkInstanceName(MediaTrack* track, bool stripPrefix=false); class BaseSurface: public IReaperControlSurface { diff --git a/src/sconscript b/src/sconscript index cb432ee..87961c7 100644 --- a/src/sconscript +++ b/src/sconscript @@ -8,6 +8,7 @@ Import("env") env.Append(CPPPATH=("#include", "#include/WDL")) sources = [ + "fxMap.cpp", "main.cpp", "niMidi.cpp", "mcu.cpp", @@ -21,7 +22,8 @@ if env["PLATFORM"] == "win32": "/clang:-std=c++20", "/EHsc", ]) - libs = ["winmm", "SetupAPI"] + sources.append(env.Object("win32_utf8.obj", "#include/WDL/WDL/win32_utf8.c")) + libs = ["winmm", "SetupAPI", "user32", "shell32", "Advapi32", "Comdlg32"] # We always want debug symbols. env.Append(PDB="${TARGET}.pdb") @@ -34,7 +36,7 @@ else: # Mac sources.append(swellDir.File("swell-modstub.mm")) libs = [] env["CXX"] = "clang++" - coreFlags = ("-mmacosx-version-min=10.7 -stdlib=libc++ " + coreFlags = ("-mmacosx-version-min=10.15 -stdlib=libc++ " "-arch x86_64 -arch arm64") cxxFlags = coreFlags + " -std=c++20" env.Append(CXXFLAGS=cxxFlags)