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 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + 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 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + 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 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + 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 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 4 + 3 + 2 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + 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;