The Smart Grid One relies entirely on a software-defined parameter system. All “knob” state lives in software, allowing for complete recall, deep modulation, macro control (gestures), and A/B scene morphing.
The core implementation spans private/src/Encoder.hpp, private/src/EncoderBank.hpp, and private/src/EncoderBankBank.hpp.
Ownership lives in EncoderBankBank: it owns a flat array of BankedEncoderCell instances indexed by SmartGridOneEncoders::Param. Each EncoderBankInternal starts with null base cells and receives raw pointers through PlaceEncoder(...). This makes encoder swapping explicit and keeps base cells empty until they are placed.
Serialization follows ownership: EncoderBankBank::ToJSON() iterates the full encoder array and writes each named parameter, and FromJSON() does the inverse by looking up each name and updating the encoder state.
The shared SmartGridOneContext is a constructor dependency throughout the
encoder ownership chain. It provides the scene manager, recorder, and parameter
event logger:
EncoderBankBank(numBanks, numModes, numEncoders, context) stores the context used by every CreateEncoder(...) call.SmartGridOneEncoders(context, numTrios, voicesPerTrio) initializes modes, banks, and named parameters during construction.SquiggleBoyWithEncoderBank(context) constructs that encoder system for the Nonagon’s trio/voice layout.The context must outlive these objects. Encoder cells serialize through
EncoderBankBank; the discrete-control State/StateSaver storage stays separate.
SetAndRecordValue updates a stored scene/track value and emits EncoderSet while
recording. The event converts normalized storage through ToValue, matching
ordinary patch JSON before smoothing or modulation. SetActive records gesture
activation; when activation inherits a parent’s value, that copied value also
passes through SetAndRecordValue. Root names identify parameters, and each
nested path hop identifies a gesture or modulator index.
The recorder groups these assignments with StateChange, GestureSet (fader), and BlendSet events. The Python reader reconstructs values and activation, including neutral nested nodes first created after the header snapshot. FromJSON writes raw values and activation flags; one PatchLoad event captures the whole load, including replacement/removal of children. Gestures are always leaves; normal modulators may nest or have gesture leaves. See the exact recording protocol and limitations.
To accommodate polyphony and quadraphonic effects, the parameter system is structured hierarchically.
Crucially, the base value of a knob is identical for all voices within a track. However, modulation and gestures can apply different offsets to each voice within that track.
StateEncoderCell: The base class that holds the actual numerical state. It stores a value in the normalized range [0, 1].BankedEncoderCell: Extends the state cell to support deep, polyphonic modulation and macro gestures.Every state encoder has an m_bipolar flag. Scene storage, gesture targets,
computed outputs, slew state, and UI/MIDI knob coordinates stay in [0, 1].
DSP GetValue and GetValueNoSlew getters automatically return 2u - 1 for a
bipolar encoder and u for a unipolar encoder. Switch indexing still uses the
normalized position. The parameter declaration’s trailing bipolar flag and
default value configure this behavior; defaults are in the exposed knob domain
and are converted to normalized state at creation. Existing top-level parameters
remain unipolar. Gesture target cells inherit their parent’s polarity.
JSON saves the knob position, in [-1, 1] for bipolar encoders and [0, 1]
for unipolar encoders. Loading converts signed values back to normalized storage.
Polarity comes from the parameter declaration or child role, not a saved field;
there is no patch version or legacy-depth conversion.
Every BankedEncoderCell can act as a modulation destination.
BankedEncoderCell. This means you can modulate the modulation depth (e.g., using an LFO to slowly fade in an envelope’s effect on the filter cutoff).0.5 meaning no modulation.
Their signed knob position b maps to effective depth
sign(b) * (9^abs(b) - 1) / 8. Signed positions -1, -0.5, 0, 0.5, 1
therefore produce depths -1, -0.25, 0, 0.25, 1. The parent applies this curve
after the child’s recursive normalized computation, once per modulation route.1.0 means the parameter is 100% controlled by the modulator, completely overriding the base knob value.Negative depths crossfade toward the inverted source, 1 - m. For each voice,
let d be each curved depth multiplied by its source amplitude and W = sum(abs(d)).
The output is p * max(0, 1 - W) + (sum(d * m) + sum(max(0, -d))) / max(1, W).
The negative-depth offset keeps the result normalized, while total absolute
weight above one produces a normalized modulation mix. Source amplitude changes
invalidate the cached result even when the waveform value stays constant.
Depth reset, activity detection, and garbage collection use the neutral position
0.5. Non-neutral saved scenes and active nested modulation remain preserved,
even when the current scene blend produces zero depth. Bipolar encoder rings
show a center marker; encoder-set messages and MIDI values remain normalized.
Old saved positive modulation depths are read as positive signed knob positions.
An old 0 stays neutral and 1 stays full depth, while an old 0.5 now produces
depth 0.25. This intentionally weakens intermediate old depths, including those
in nested modulation. Saving writes the knob position, so repeated save/load does
not repeatedly apply the exponential curve. Changing an existing top-level
parameter to bipolar likewise requires deliberately updating its DSP callers and
accepting the new interpretation of its old saved values.
Gestures are macro controls mapped to physical analog inputs (Like sliders and joysticks).
0.0, the parameter sits at its base knob value.1.0, the parameter moves to the Target State.BankedEncoderCell (though hidden from the normal UI) and is polyphonic.The entire state of all encoders (base values, modulation depths, and gesture targets) is stored across 8 persistent Scenes (SceneManager::x_numScenes, in SceneManager.hpp). Each scene is a complete snapshot of the synthesizer.
m_scene1 and m_scene2. A global m_blendFactor crossfades between just those two.GetSceneValue reads each parameter’s per-scene array and interpolates values[m_scene1] and values[m_scene2] by m_blendFactor.EncoderBankUIState: Manages the communication between the deep software state and the physical hardware/screen UI. It handles the rendering of LED rings and the processing of delta increments from physical endless encoders.ParamSlew or FixedSlew) to the final, post-modulation parameter values before using them in audio calculations. During oversampled blocks, this slew rate is adjusted automatically.The Partial Machine uses a Quad bank differently from Delay and Reverb. Its four lanes are interpreted as anchors for FrequencyDependentParameter, not just four speaker channels. If all four lanes share the same base value, the parameter behaves like a normal scalar. If modulation produces different values per lane, each spectral partial interpolates a unique value from its frequency, allowing the same knob to create frequency-dependent attack, decay, density, bandwidth, panning, unison, pitch, and volume behavior.
See Partial Machine for the DSP-side mapping.
Each parameter in the Voice banks can specify which source and filter machines it applies to via bit vectors (MachineFlags). Parameters are defined in ForEachSmartGridOneParam.hpp with sourceMachines and filterMachines arguments (e.g. MachineFlags::x_dualWaveShapingVCOOnly for parameters that only affect the Dual Wave Shaping VCO source).
When the user selects a different source machine (e.g. Thru) or filter machine, UpdateEncodersForMachine() runs and swaps the actual encoder pointers in the grid. Parameters that do not apply are removed by placing nullptr in those positions, which leaves the cell empty and disconnected. This keeps the encoder grid relevant to the active machine while preserving encoder state in the owner array. The update is triggered on machine change (config page) and track change (since each track can have different machines).