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.
-
+
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