diff --git a/plugin.json b/plugin.json
index 9b6ec77..2933f0b 100644
--- a/plugin.json
+++ b/plugin.json
@@ -111,7 +111,8 @@
"Envelope generator",
"Mixer",
"Polyphonic",
- "Hardware clone"
+ "Hardware clone",
+ "Quad"
]
},
{
@@ -157,12 +158,27 @@
"name": "Sampling Modulator",
"description": "Multi-function module that lies somewhere between a VCO, a Sample & Hold, and an 8 step trigger sequencer",
"manualUrl": "https://www.befaco.org/sampling-modulator/",
- "modularGridUrl": "https://www.modulargrid.net/e/befaco-sampling-modulator-",
- "tags": [
- "Sample and hold",
+ "modularGridUrl": "https://www.modulargrid.net/e/befaco-sampling-modulator-",
+ "tags": [
+ "Clock generator",
+ "Hardware clone",
"Oscillator",
- "Clock generator"
+ "Sample and hold"
+ ]
+ },
+ {
+ "slug": "Morphader",
+ "name": "Morphader",
+ "description": "Multichannel CV/Audio crossfader",
+ "manualUrl": "https://www.befaco.org/morphader-2/",
+ "modularGridUrl": "https://www.modulargrid.net/e/befaco-morphader",
+ "tags": [
+ "Controller",
+ "Hardware clone",
+ "Mixer",
+ "Polyphonic",
+ "Quad"
]
}
]
-}
+}
\ No newline at end of file
diff --git a/res/BefacoTinyKnobBlack.svg b/res/BefacoTinyKnobBlack.svg
new file mode 100644
index 0000000..c72289e
--- /dev/null
+++ b/res/BefacoTinyKnobBlack.svg
@@ -0,0 +1,85 @@
+
+
diff --git a/res/BefacoTinyKnobGrey.svg b/res/BefacoTinyKnobDarkGrey.svg
similarity index 100%
rename from res/BefacoTinyKnobGrey.svg
rename to res/BefacoTinyKnobDarkGrey.svg
diff --git a/res/BefacoTinyKnobLightGrey.svg b/res/BefacoTinyKnobLightGrey.svg
new file mode 100644
index 0000000..8776e38
--- /dev/null
+++ b/res/BefacoTinyKnobLightGrey.svg
@@ -0,0 +1,85 @@
+
+
diff --git a/res/CrossfaderBackground.svg b/res/CrossfaderBackground.svg
new file mode 100644
index 0000000..fbd75d1
--- /dev/null
+++ b/res/CrossfaderBackground.svg
@@ -0,0 +1,85 @@
+
+
diff --git a/res/CrossfaderHandle.svg b/res/CrossfaderHandle.svg
new file mode 100644
index 0000000..c21b10e
--- /dev/null
+++ b/res/CrossfaderHandle.svg
@@ -0,0 +1,113 @@
+
+
diff --git a/res/Morphader.svg b/res/Morphader.svg
new file mode 100644
index 0000000..7fbccce
--- /dev/null
+++ b/res/Morphader.svg
@@ -0,0 +1,1751 @@
+
+
diff --git a/res/SwitchNarrow_0.svg b/res/SwitchNarrow_0.svg
new file mode 100644
index 0000000..b5f0c18
--- /dev/null
+++ b/res/SwitchNarrow_0.svg
@@ -0,0 +1,114 @@
+
+
diff --git a/res/SwitchNarrow_1.svg b/res/SwitchNarrow_1.svg
new file mode 100644
index 0000000..b71fd86
--- /dev/null
+++ b/res/SwitchNarrow_1.svg
@@ -0,0 +1,114 @@
+
+
diff --git a/src/Kickall.cpp b/src/Kickall.cpp
index 6be5259..a97fb13 100644
--- a/src/Kickall.cpp
+++ b/src/Kickall.cpp
@@ -123,7 +123,7 @@ struct KickallWidget : ModuleWidget {
addChild(createWidget(Vec(RACK_GRID_WIDTH, 0)));
addChild(createWidget(Vec(RACK_GRID_WIDTH, RACK_GRID_HEIGHT - RACK_GRID_WIDTH)));
- addParam(createParamCentered(mm2px(Vec(8.472, 28.97)), module, Kickall::TUNE_PARAM));
+ addParam(createParamCentered(mm2px(Vec(8.472, 28.97)), module, Kickall::TUNE_PARAM));
addParam(createParamCentered(mm2px(Vec(22.409, 29.159)), module, Kickall::TRIGG_BUTTON_PARAM));
addParam(createParamCentered(mm2px(Vec(15.526, 49.292)), module, Kickall::SHAPE_PARAM));
addParam(createParam(mm2px(Vec(19.667, 63.897)), module, Kickall::DECAY_PARAM));
diff --git a/src/Morphader.cpp b/src/Morphader.cpp
new file mode 100644
index 0000000..9ea89f2
--- /dev/null
+++ b/src/Morphader.cpp
@@ -0,0 +1,289 @@
+#include "plugin.hpp"
+
+
+// equal sum crossfade, -1 <= p <= 1
+template
+inline T equalSumCrossfade(T a, T b, const float p) {
+ return a * (0.5f * (1.f - p)) + b * (0.5f * (1.f + p));
+}
+
+// equal power crossfade, -1 <= p <= 1
+template
+inline T equalPowerCrossfade(T a, T b, const float p) {
+ // TODO: investigate more efficient representation (avoid exp)
+ return std::min(std::exp(4.f * p), 1.f) * b + std::min(std::exp(4.f * -p), 1.f) * a;
+}
+
+// TExponentialSlewLimiter doesn't appear to work correctly (tried for -1 -> +1, and 0 -> 1)
+// TODO: confirm, or explain better how it doesn't work
+struct ExpLogSlewLimiter {
+
+ float out = 0.f;
+ float slew = 0.f;
+
+ void reset() {
+ out = 0.f;
+ }
+
+ void setSlew(float slew) {
+ this->slew = slew;
+ }
+ float process(float deltaTime, float in) {
+ if (in > out) {
+ out += slew * (in - out) * deltaTime;
+ if (out > in) {
+ out = in;
+ }
+ }
+ else if (in < out) {
+ out -= slew * (out - in) * deltaTime;
+ if (out < in) {
+ out = in;
+ }
+ }
+ return out;
+ }
+};
+
+
+struct Morphader : Module {
+ enum ParamIds {
+ CV_PARAM,
+ ENUMS(A_LEVEL, 4),
+ ENUMS(B_LEVEL, 4),
+ ENUMS(MODE, 4),
+ FADER_LAG_PARAM,
+ FADER_PARAM,
+ NUM_PARAMS
+ };
+ enum InputIds {
+ ENUMS(CV_INPUT, 4),
+ ENUMS(A_INPUT, 4),
+ ENUMS(B_INPUT, 4),
+ NUM_INPUTS
+ };
+ enum OutputIds {
+ ENUMS(OUT, 4),
+ NUM_OUTPUTS
+ };
+ enum LightIds {
+ ENUMS(A_LED, 4),
+ ENUMS(B_LED, 4),
+ NUM_LIGHTS
+ };
+ enum CrossfadeMode {
+ AUDIO_MODE,
+ CV_MODE
+ };
+
+ static const int NUM_MIXER_CHANNELS = 4;
+ const simd::float_4 normal10VSimd = {10.f};
+ ExpLogSlewLimiter slewLimiter;
+
+ // minimum and maximum slopes in volts per second, they specify the time to get
+ // from A (-1) to B (+1)
+ constexpr static float slewMin = 2.0 / 15.f;
+ constexpr static float slewMax = 2.0 / 0.01f;
+
+ struct AudioCVModeParam : ParamQuantity {
+ std::string getDisplayValueString() override {
+ switch (static_cast(getValue())) {
+ case AUDIO_MODE: return "Audio";
+ case CV_MODE: return "CV";
+ default: assert(false);
+ }
+ }
+ };
+
+ struct FaderLagParam : ParamQuantity {
+ std::string getDisplayValueString() override {
+ const float slewTime = 2.f / (slewMax * std::pow(slewMin / slewMax, getValue()));
+ return string::f("%.3gs", slewTime);
+ }
+ };
+
+ Morphader() {
+ config(NUM_PARAMS, NUM_INPUTS, NUM_OUTPUTS, NUM_LIGHTS);
+
+ configParam(CV_PARAM, 0.f, 1.f, 1.f, "CV");
+
+ for (int i = 0; i < NUM_MIXER_CHANNELS; i++) {
+ configParam(A_LEVEL + i, 0.f, 1.f, 0.f, "A level " + std::to_string(i + 1));
+ }
+ for (int i = 0; i < NUM_MIXER_CHANNELS; i++) {
+ configParam(B_LEVEL + i, 0.f, 1.f, 0.f, "B level " + std::to_string(i + 1));
+ }
+ for (int i = 0; i < NUM_MIXER_CHANNELS; i++) {
+ configParam(MODE + i, AUDIO_MODE, CV_MODE, AUDIO_MODE, "Mode " + std::to_string(i + 1));
+ }
+
+ configParam(FADER_LAG_PARAM, 0.f, 1.f, 0.f, "Fader lag");
+ configParam(FADER_PARAM, -1.f, 1.f, 0.f, "Fader");
+ }
+
+ // determine the cross-fade between -1 (A) and +1 (B) for each of the 4 channels
+ simd::float_4 determineChannelCrossfades(const float deltaTime) {
+
+ simd::float_4 channelCrossfades = {0.f};
+
+ slewLimiter.setSlew(slewMax * std::pow(slewMin / slewMax, params[FADER_LAG_PARAM].getValue()));
+ const float masterCrossfadeValue = slewLimiter.process(deltaTime, params[FADER_PARAM].getValue());
+
+ for (int i = 0; i < NUM_MIXER_CHANNELS; i++) {
+
+ if (i == 0) {
+ // CV will be added to master for channel 1, and if not connected, the normalled value of 5.0V will correspond to the midpoint
+ const float crossfadeCV = clamp(inputs[CV_INPUT + i].getNormalVoltage(5.f), 0.f, 10.f);
+ channelCrossfades[i] = params[CV_PARAM].getValue() * rescale(crossfadeCV, 0.f, 10.f, 0.f, +2.f) + masterCrossfadeValue;
+ }
+ else {
+ // if present for the current channel, CV has total control (crossfader is ignored)
+ if (inputs[CV_INPUT + i].isConnected()) {
+ const float crossfadeCV = clamp(inputs[CV_INPUT + i].getVoltage(), 0.f, 10.f);
+ channelCrossfades[i] = rescale(crossfadeCV, 0.f, 10.f, -1.f, +1.f);
+ }
+ // if channel 1 is plugged in, use that
+ else if (inputs[CV_INPUT + 0].isConnected()) {
+ // TODO: is this right, or is is channelCrossfades[i] (i.e. with master fader)?
+ const float crossfadeCV = clamp(inputs[CV_INPUT + 0].getVoltage(), 0.f, 10.f);
+ channelCrossfades[i] = params[CV_PARAM].getValue() * rescale(crossfadeCV, 0.f, 10.f, -1.f, +1.f);
+ }
+ else {
+ channelCrossfades[i] = masterCrossfadeValue;
+ }
+ }
+ }
+
+ return channelCrossfades;
+ }
+
+ void process(const ProcessArgs& args) override {
+
+ int maxChannels = 1;
+ simd::float_4 mix[4] = {0.f};
+ const simd::float_4 channelCrossfades = determineChannelCrossfades(args.sampleTime);
+
+ for (int i = 0; i < NUM_MIXER_CHANNELS; i++) {
+
+ const int channels = std::max(std::max(inputs[A_INPUT + i].getChannels(), inputs[B_INPUT + i].getChannels()), 1);
+ // keep track of the max number of channels for the mix output, noting that if channels are taken out of the mix
+ // (i.e. they're connected) they shouldn't contribute to the polyphony calculation
+ if (!outputs[OUT + i].isConnected()) {
+ maxChannels = std::max(maxChannels, channels);
+ }
+
+ simd::float_4 out[4] = {0.f};
+ for (int c = 0; c < channels; c += 4) {
+ simd::float_4 inA = inputs[A_INPUT + i].getNormalVoltageSimd(normal10VSimd, c) * params[A_LEVEL + i].getValue();
+ simd::float_4 inB = inputs[B_INPUT + i].getNormalVoltageSimd(normal10VSimd, c) * params[B_LEVEL + i].getValue();
+
+ switch (static_cast(params[MODE + i].getValue())) {
+ case CV_MODE: {
+ out[c / 4] = equalSumCrossfade(inA, inB, channelCrossfades[i]);
+ break;
+ }
+ case AUDIO_MODE: {
+ out[c / 4] = equalPowerCrossfade(inA, inB, channelCrossfades[i]);
+ break;
+ }
+ default: assert(false);
+ }
+ }
+
+ // if output is patched, the channel is taken out of the mix
+ if (outputs[OUT + i].isConnected() && i != NUM_MIXER_CHANNELS - 1) {
+ outputs[OUT + i].setChannels(channels);
+ for (int c = 0; c < channels; c += 4) {
+ out[c / 4].store(outputs[OUT + i].getVoltages(c));
+ }
+ }
+ else {
+ for (int c = 0; c < channels; c += 4) {
+ mix[c / 4] += out[c / 4];
+ }
+ }
+
+ if (i == NUM_MIXER_CHANNELS - 1) {
+ outputs[OUT + i].setChannels(maxChannels);
+
+ for (int c = 0; c < maxChannels; c += 4) {
+ mix[c / 4].store(outputs[OUT + i].getVoltages(c));
+ }
+ }
+
+ switch (static_cast(params[MODE + i].getValue())) {
+ case AUDIO_MODE: {
+ lights[A_LED + i].setBrightness(equalPowerCrossfade(1.f, 0.f, channelCrossfades[i]));
+ lights[B_LED + i].setBrightness(equalPowerCrossfade(0.f, 1.f, channelCrossfades[i]));
+ break;
+ }
+ case CV_MODE: {
+ lights[A_LED + i].setBrightness(equalSumCrossfade(1.f, 0.f, channelCrossfades[i]));
+ lights[B_LED + i].setBrightness(equalSumCrossfade(0.f, 1.f, channelCrossfades[i]));
+ break;
+ }
+ default: assert(false);
+ }
+ } // end loop over mixer channels
+ }
+};
+
+
+struct MorphaderWidget : ModuleWidget {
+ MorphaderWidget(Morphader* module) {
+ setModule(module);
+ setPanel(APP->window->loadSvg(asset::plugin(pluginInstance, "res/Morphader.svg")));
+
+ addChild(createWidget(Vec(RACK_GRID_WIDTH, 0)));
+ addChild(createWidget(Vec(box.size.x - 2 * RACK_GRID_WIDTH, 0)));
+ addChild(createWidget(Vec(RACK_GRID_WIDTH, RACK_GRID_HEIGHT - RACK_GRID_WIDTH)));
+ addChild(createWidget(Vec(box.size.x - 2 * RACK_GRID_WIDTH, RACK_GRID_HEIGHT - RACK_GRID_WIDTH)));
+
+ addParam(createParamCentered(mm2px(Vec(10.817, 15.075)), module, Morphader::CV_PARAM));
+ addParam(createParamCentered(mm2px(Vec(30.243, 30.537)), module, Morphader::A_LEVEL + 0));
+ addParam(createParamCentered(mm2px(Vec(30.243, 48.017)), module, Morphader::A_LEVEL + 1));
+ addParam(createParamCentered(mm2px(Vec(30.243, 65.523)), module, Morphader::A_LEVEL + 2));
+ addParam(createParamCentered(mm2px(Vec(30.243, 83.051)), module, Morphader::A_LEVEL + 3));
+ addParam(createParamCentered(mm2px(Vec(52.696, 30.537)), module, Morphader::B_LEVEL + 0));
+ addParam(createParamCentered(mm2px(Vec(52.696, 48.017)), module, Morphader::B_LEVEL + 1));
+ addParam(createParamCentered(mm2px(Vec(52.696, 65.523)), module, Morphader::B_LEVEL + 2));
+ addParam(createParamCentered(mm2px(Vec(52.696, 83.051)), module, Morphader::B_LEVEL + 3));
+ addParam(createParam(mm2px(Vec(39.775, 28.107)), module, Morphader::MODE + 0));
+ addParam(createParam(mm2px(Vec(39.775, 45.627)), module, Morphader::MODE + 1));
+ addParam(createParam(mm2px(Vec(39.775, 63.146)), module, Morphader::MODE + 2));
+ addParam(createParam(mm2px(Vec(39.775, 80.666)), module, Morphader::MODE + 3));
+ addParam(createParamCentered(mm2px(Vec(10.817, 99.242)), module, Morphader::FADER_LAG_PARAM));
+ addParam(createParamCentered(mm2px(Vec(30., 114.25)), module, Morphader::FADER_PARAM));
+
+ addInput(createInputCentered(mm2px(Vec(25.214, 14.746)), module, Morphader::CV_INPUT + 0));
+ addInput(createInputCentered(mm2px(Vec(35.213, 14.746)), module, Morphader::CV_INPUT + 1));
+ addInput(createInputCentered(mm2px(Vec(45.236, 14.746)), module, Morphader::CV_INPUT + 2));
+ addInput(createInputCentered(mm2px(Vec(55.212, 14.746)), module, Morphader::CV_INPUT + 3));
+ addInput(createInputCentered(mm2px(Vec(5.812, 32.497)), module, Morphader::A_INPUT + 0));
+ addInput(createInputCentered(mm2px(Vec(5.812, 48.017)), module, Morphader::A_INPUT + 1));
+ addInput(createInputCentered(mm2px(Vec(5.812, 65.523)), module, Morphader::A_INPUT + 2));
+ addInput(createInputCentered(mm2px(Vec(5.812, 81.185)), module, Morphader::A_INPUT + 3));
+ addInput(createInputCentered(mm2px(Vec(15.791, 32.497)), module, Morphader::B_INPUT + 0));
+ addInput(createInputCentered(mm2px(Vec(15.791, 48.017)), module, Morphader::B_INPUT + 1));
+ addInput(createInputCentered(mm2px(Vec(15.791, 65.523)), module, Morphader::B_INPUT + 2));
+ addInput(createInputCentered(mm2px(Vec(15.791, 81.185)), module, Morphader::B_INPUT + 3));
+
+ addOutput(createOutputCentered(mm2px(Vec(25.177, 100.5)), module, Morphader::OUT + 0));
+ addOutput(createOutputCentered(mm2px(Vec(35.177, 100.5)), module, Morphader::OUT + 1));
+ addOutput(createOutputCentered(mm2px(Vec(45.177, 100.5)), module, Morphader::OUT + 2));
+ addOutput(createOutputCentered(mm2px(Vec(55.176, 100.5)), module, Morphader::OUT + 3));
+
+ addChild(createLightCentered>(mm2px(Vec(37.594, 24.378)), module, Morphader::A_LED + 0));
+ addChild(createLightCentered>(mm2px(Vec(37.594, 41.908)), module, Morphader::A_LED + 1));
+ addChild(createLightCentered>(mm2px(Vec(37.594, 59.488)), module, Morphader::A_LED + 2));
+ addChild(createLightCentered>(mm2px(Vec(37.594, 76.918)), module, Morphader::A_LED + 3));
+
+ addChild(createLightCentered>(mm2px(Vec(45.332, 24.378)), module, Morphader::B_LED + 0));
+ addChild(createLightCentered>(mm2px(Vec(45.332, 41.908)), module, Morphader::B_LED + 1));
+ addChild(createLightCentered>(mm2px(Vec(45.332, 59.488)), module, Morphader::B_LED + 2));
+ addChild(createLightCentered>(mm2px(Vec(45.332, 76.918)), module, Morphader::B_LED + 3));
+ }
+};
+
+
+Model* modelMorphader = createModel("Morphader");
\ No newline at end of file
diff --git a/src/plugin.cpp b/src/plugin.cpp
index 4e90298..7b59da0 100644
--- a/src/plugin.cpp
+++ b/src/plugin.cpp
@@ -18,4 +18,5 @@ void init(rack::Plugin *p) {
p->addModel(modelChoppingKinky);
p->addModel(modelKickall);
p->addModel(modelSamplingModulator);
+ p->addModel(modelMorphader);
}
diff --git a/src/plugin.hpp b/src/plugin.hpp
index c1590ae..658ca70 100644
--- a/src/plugin.hpp
+++ b/src/plugin.hpp
@@ -18,6 +18,7 @@ extern Model *modelHexmixVCA;
extern Model *modelChoppingKinky;
extern Model *modelKickall;
extern Model *modelSamplingModulator;
+extern Model *modelMorphader;
struct Knurlie : SvgScrew {
@@ -44,11 +45,27 @@ struct BefacoTinyKnobWhite : app::SvgKnob {
}
};
-struct BefacoTinyKnobGrey : app::SvgKnob {
- BefacoTinyKnobGrey() {
+struct BefacoTinyKnobDarkGrey : app::SvgKnob {
+ BefacoTinyKnobDarkGrey() {
minAngle = -0.8 * M_PI;
maxAngle = 0.8 * M_PI;
- setSvg(APP->window->loadSvg(asset::plugin(pluginInstance, "res/BefacoTinyKnobGrey.svg")));
+ setSvg(APP->window->loadSvg(asset::plugin(pluginInstance, "res/BefacoTinyKnobDarkGrey.svg")));
+ }
+};
+
+struct BefacoTinyKnobLightGrey : app::SvgKnob {
+ BefacoTinyKnobLightGrey() {
+ minAngle = -0.8 * M_PI;
+ maxAngle = 0.8 * M_PI;
+ setSvg(APP->window->loadSvg(asset::plugin(pluginInstance, "res/BefacoTinyKnobLightGrey.svg")));
+ }
+};
+
+struct BefacoTinyKnobBlack : app::SvgKnob {
+ BefacoTinyKnobBlack() {
+ minAngle = -0.8 * M_PI;
+ maxAngle = 0.8 * M_PI;
+ setSvg(APP->window->loadSvg(asset::plugin(pluginInstance, "res/BefacoTinyKnobBlack.svg")));
}
};
@@ -70,6 +87,26 @@ struct BefacoInputPort : app::SvgPort {
}
};
+struct CKSSNarrow : app::SvgSwitch {
+ CKSSNarrow() {
+ addFrame(APP->window->loadSvg(asset::plugin(pluginInstance, "res/SwitchNarrow_0.svg")));
+ addFrame(APP->window->loadSvg(asset::plugin(pluginInstance, "res/SwitchNarrow_1.svg")));
+ }
+};
+
+struct Crossfader : app::SvgSlider {
+ Crossfader() {
+ setBackgroundSvg(APP->window->loadSvg(asset::plugin(pluginInstance, "res/CrossfaderBackground.svg")));
+ setHandleSvg(APP->window->loadSvg(asset::plugin(pluginInstance, "res/CrossfaderHandle.svg")));
+ minHandlePos = mm2px(Vec(4.5f, -0.8f));
+ maxHandlePos = mm2px(Vec(34.5, -0.8f));
+ horizontal = true;
+ math::Vec margin = math::Vec(15, 5);
+ background->box.pos = margin;
+ box.size = background->box.size.plus(margin.mult(2));
+ }
+};
+
template
T sin2pi_pade_05_5_4(T x) {
x -= 0.5f;