diff --git a/README.md b/README.md index 6d29ea6..575ae8a 100644 --- a/README.md +++ b/README.md @@ -6,5 +6,6 @@ A VCV Rack 2 plugin from Berserk Audio. - **Scrambler** โ€” Alternates between clean passthrough and randomly reordered individual samples. - **Xenizer** โ€” Microtonal quantizer with preset scales. +- **XenScribe** *(not in MetaModule plugin)* โ€” Microtonal quantizer that takes a custom scale typed in as cents. -Scrambler panel Xenizer panel +Scrambler panel Xenizer panel XenScribe panel diff --git a/docs/xenscribe-panel.png b/docs/xenscribe-panel.png new file mode 100644 index 0000000..18f6334 Binary files /dev/null and b/docs/xenscribe-panel.png differ diff --git a/plugin.json b/plugin.json index 6851abb..b73ac77 100644 --- a/plugin.json +++ b/plugin.json @@ -23,6 +23,14 @@ "tags": [ "Quantizer" ] + }, + { + "slug": "XenScribe", + "name": "XenScribe", + "description": "Microtonal quantizer with text-input scale (cents).", + "tags": [ + "Quantizer" + ] } ] } diff --git a/res/XenScribe.svg b/res/XenScribe.svg new file mode 100644 index 0000000..2db95a8 --- /dev/null +++ b/res/XenScribe.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + diff --git a/src/XenScribe.cpp b/src/XenScribe.cpp new file mode 100644 index 0000000..22c828c --- /dev/null +++ b/src/XenScribe.cpp @@ -0,0 +1,164 @@ +#include "plugin.hpp" +#include +#include +#include +#include + + +struct XenScribe : Module +{ + enum ParamId + { + PARAMS_LEN + }; + enum InputId + { + PITCH_INPUT, + INPUTS_LEN + }; + enum OutputId + { + PITCH_OUTPUT, + OUTPUTS_LEN + }; + + // Newline-separated cents from the root (which is implicit at 0). The + // last value is the period. Default is 12-TET so a fresh module is usable. + std::string scaleText = "100\n200\n300\n400\n500\n600\n700\n800\n900\n1000\n1100\n1200"; + std::vector pitches; + float period_cents = 1200.f; + + // UI thread sets scaleDirty when the user types; the audio thread re-parses + // on the next process() so the vector is only mutated from one thread. + bool scaleDirty = true; + // Set by dataFromJson so the widget pulls the loaded string into its + // displayed text on the next draw(). + bool manualSet = true; + + XenScribe() + { + config(PARAMS_LEN, INPUTS_LEN, OUTPUTS_LEN); + configInput(PITCH_INPUT, "Pitch"); + configOutput(PITCH_OUTPUT, "Pitch"); + } + + void parseScale() + { + std::vector parsed; + std::stringstream ss(scaleText); + std::string token; + while (std::getline(ss, token)) + { + try + { + parsed.push_back(std::stof(token)); + } + catch (...) + { + } + } + if (parsed.size() >= 2 && parsed.back() > 0.f) + { + pitches = parsed; + period_cents = pitches.back(); + } + } + + void process(const ProcessArgs &args) override + { + if (scaleDirty) + { + parseScale(); + scaleDirty = false; + } + if (pitches.size() < 2 || period_cents <= 0.f) + return; + + float input_cents = inputs[PITCH_INPUT].getVoltage() * 1200.f; + float period_idx_f = std::floor(input_cents / period_cents); + int period_idx = (int)period_idx_f; + float remainder = input_cents - period_idx_f * period_cents; + + // Implicit root at 0ยข is the initial best; the last pitch equals + // period_cents and doubles as snap-up-to-next-period. + float best_pitch = 0.f; + float best_dist = std::fabs(remainder); + for (float p : pitches) + { + float d = std::fabs(remainder - p); + if (d < best_dist) + { + best_dist = d; + best_pitch = p; + } + } + + float output_cents = period_idx * period_cents + best_pitch; + outputs[PITCH_OUTPUT].setVoltage(output_cents / 1200.f); + } + + json_t *dataToJson() override + { + json_t *root = json_object(); + json_object_set_new(root, "scale", json_string(scaleText.c_str())); + return root; + } + + void dataFromJson(json_t *root) override + { + json_t *j = json_object_get(root, "scale"); + if (j) + { + scaleText = json_string_value(j); + scaleDirty = true; + manualSet = true; + } + } +}; + + +struct ScaleTextField : ui::TextField +{ + XenScribe *module = nullptr; + + void draw(const DrawArgs &args) override + { + if (module) + { + if (module->manualSet) + { + setText(module->scaleText); + module->manualSet = false; + } + else if (text != module->scaleText) + { + module->scaleText = text; + module->scaleDirty = true; + } + } + ui::TextField::draw(args); + } +}; + + +struct XenScribeWidget : ModuleWidget +{ + XenScribeWidget(XenScribe *module) + { + setModule(module); + setPanel(createPanel(asset::plugin(pluginInstance, "res/XenScribe.svg"))); + + auto *tf = createWidget(mm2px(Vec(2, 2))); + tf->box.size = mm2px(Vec(36.64, 106)); + tf->multiline = true; + tf->module = module; + if (module) + tf->setText(module->scaleText); + addChild(tf); + + addInput(createInputCentered(mm2px(Vec(10, 117)), module, XenScribe::PITCH_INPUT)); + addOutput(createOutputCentered(mm2px(Vec(30.64, 117)), module, XenScribe::PITCH_OUTPUT)); + } +}; + +Model *modelXenScribe = createModel("XenScribe"); diff --git a/src/plugin.cpp b/src/plugin.cpp index 49db584..2605086 100644 --- a/src/plugin.cpp +++ b/src/plugin.cpp @@ -9,4 +9,7 @@ void init(Plugin* p) { p->addModel(modelScrambler); p->addModel(modelXenizer); +#ifndef METAMODULE + p->addModel(modelXenScribe); +#endif } diff --git a/src/plugin.hpp b/src/plugin.hpp index 4e52cb4..cdfed42 100644 --- a/src/plugin.hpp +++ b/src/plugin.hpp @@ -10,3 +10,6 @@ extern Plugin* pluginInstance; // Declare each Model, defined in each module source file extern Model* modelScrambler; extern Model* modelXenizer; +#ifndef METAMODULE +extern Model* modelXenScribe; +#endif