Parameters and state
What a host can automate, and what a saved session brings back.
On this page
The parameter table
A plugin’s parameters are a sonore::ParamInfo table that the descriptor points
at. Its fields, in the order an aggregate initialiser fills them:
| Field | |
|---|---|
id, label, unit |
a stable machine id (never shown), the name a user reads, a unit such as "dB" |
minValue, maxValue, defaultValue |
the range and default, in PLAIN units: the numbers the user sees |
stepCount |
above 0, a stepped control with that many positions; 0 is continuous |
group |
a display group such as "Filter"; each format has a place for it |
valueNames, numValueNames |
what each step is called, so automation reads “Highpass” rather than “2” |
automatable, hidden, readOnly |
whether a host may record it, show it, or write it |
skew |
how the range spreads over a knob’s travel; sonore::skewForCentre(min, max, centre) computes it |
The table is positional. A parameter’s index is its identity to every host and in every saved session. A shipped plugin only ever appends rows: reordering or removing one makes old sessions load values into the wrong controls.
The wrappers and the native editor turn values into text with
sonore::formatParamValue and typed text back into values with
sonore::parseParamValue, and the web page’s sonore.format mirrors the same
rule, so the host’s readout, the knob and the page never disagree.
State beyond the parameters
The state every format saves holds the parameter values, the bypass, a few
things of the SDK’s own (the preset the user is on, the editor’s size) and
whatever the DSP adds. A DSP with more to remember – which file it loaded, a
curve the user drew – declares saveState and loadState over a
sonore::StateBag, a list of keys and values. Include <sonore/state_bag.h>
before the struct, and make saveState const: the wrappers look for it on a
const DSP.
A host only knows a session changed when the host changed something. A plugin
that changes its own state – a file picked in its editor – says so through
consumeStateDirty(), which returns true once per change, or the DAW closes
without offering to save.
#define SONORE_NUM_PARAMS 3
#include <sonore/dsp.h>
#include <sonore/state_bag.h>
#include <atomic>
#include <string>
struct SonoreDsp {
sonore::SVF filter[2];
sonore::Smooth cutoffSm, outSm;
bool fresh = true;
// Main-thread state: the file the user picked, remembered with the session.
// process() never touches it.
std::string curvePath;
std::atomic<bool> stateDirty{false};
void prepare(const sonore::ProcessSpec& spec) {
for (auto& f : filter) {
f.setSampleRate((float) spec.sampleRate);
f.reset();
}
cutoffSm.setup((float) spec.sampleRate, 20.0f);
outSm.setup((float) spec.sampleRate, 20.0f);
fresh = true;
}
void process(sonore::AudioBlock<float>& io, const float* p) {
const int mode = (int) (p[1] + 0.5f); // a stepped parameter arrives on a step
const float out = sonore::dbToGain(p[2]);
if (fresh) {
cutoffSm.snap(p[0]);
outSm.snap(out);
fresh = false;
}
const size_t channels = io.getNumChannels() < 2 ? io.getNumChannels() : 2;
for (size_t i = 0; i < io.getNumSamples(); ++i) {
const float hz = cutoffSm.next(p[0]), g = outSm.next(out);
for (size_t c = 0; c < channels; ++c) {
float& x = io.getChannelPointer(c)[i];
filter[c].set(hz, 0.707f);
filter[c].process(x);
x = (mode == 0 ? filter[c].lp : mode == 1 ? filter[c].bp : filter[c].hp) * g;
}
}
}
// [main thread] The file a web page asked for with sonore.chooseFile("curve");
// an empty path is a cancel.
void loadFile(const char* purpose, const char* path) {
if (std::string(purpose) != "curve" || !*path) return;
curvePath = path;
stateDirty.store(true);
}
bool consumeStateDirty() { return stateDirty.exchange(false); }
// The bag is versioned by the plugin itself. A key that was never written
// reads as the fallback, so a new key needs no migration at all; a key that
// CHANGED meaning is read by the version that wrote it.
void saveState(sonore::StateBag& bag) const {
bag.setInt("stateVersion", 2);
bag.setString("curvePath", curvePath);
}
void loadState(const sonore::StateBag& bag) {
const int64_t version = bag.getInt("stateVersion", 1);
// Version 1 called the same thing "file".
curvePath = version >= 2 ? bag.getString("curvePath") : bag.getString("file");
}
};
#include <sonore/plugin.h>
static const char* const kModeNames[] = {"Lowpass", "Bandpass", "Highpass"};
static const sonore::ParamInfo kParamTable[SONORE_NUM_PARAMS] = {
// A skewed control in a group: 1 kHz sits at the middle of the knob.
{"cutoff", "Cutoff", "Hz", 20.0f, 20000.0f, 1000.0f, 0, "Filter", nullptr, 0, true, false, false,
sonore::skewForCentre(20.0f, 20000.0f, 1000.0f)},
// A stepped control with named steps: 0, 1, 2 are Lowpass, Bandpass, Highpass.
{"mode", "Mode", "", 0.0f, 2.0f, 0.0f, 3, "Filter", kModeNames, 3},
{"output", "Output", "dB", -24.0f, 12.0f, 0.0f, 0, "Output"},
};
static const sonore::PluginDescriptor kDesc = {
"com.mycompany.statefilter", "State Filter", "My Company", "1.0.0",
"A filter that remembers a file.", "",
false, kParamTable, SONORE_NUM_PARAMS,
};
#include <sonore/formats.h>Versioning state
What a later version of the plugin can rely on when it opens an older session:
- Parameters. A session saved with fewer parameters loads the ones both sides share and leaves the rest at their defaults, so appending a parameter never orphans a session. A value out of range is clamped, and a NaN becomes the default.
- The bag. A missing key reads as the fallback you pass to
getString,getInt,getDoubleorgetBool. Store a version key of your own, as above, for the day a key has to change meaning. - The SDK’s own blob is versioned too, and a blob written by a NEWER build is refused rather than half-read.
sonore::StateBag::has() tells a key that was saved empty from one that was
never saved.
The other shape: AudioProcessorValueTreeState
Code ported from the reference arrives with a ParameterLayout of
AudioParameterFloat, AudioParameterChoice and AudioParameterBool.
sonore::AudioProcessorValueTreeState keeps that shape and turns it into the
same ParamInfo table: parameterInfos() is what the descriptor points at, in
layout order, so the DSP’s p[i] is layout entry i. The showcase example
(sdk/examples/showcase/plugin.cpp) is built this way, and its editor binds
widgets with the APVTS attachments.
#include <sonore/value_tree_state.h>
#include <cstdio>
#include <cstring>
#include <memory>
#include <string>
#include <vector>
sonore::AudioProcessorValueTreeState::ParameterLayout createLayout() {
using namespace sonore;
NormalisableRange<float> cutoff(20.0f, 20000.0f);
cutoff.setSkewForCentre(1000.0f);
return AudioProcessorValueTreeState::ParameterLayout(
std::make_unique<AudioParameterFloat>("cutoff", "Cutoff", cutoff, 1000.0f,
RangedAudioParameter::Attributes().withLabel("Hz")),
std::make_unique<AudioParameterChoice>(
"mode", "Mode", std::vector<std::string>{"Lowpass", "Bandpass", "Highpass"}, 0),
std::make_unique<AudioParameterBool>("bypass", "Bypass", false));
}
int main() {
// No undo history here; an editor passes one (UndoHistory) to get undo.
const sonore::AudioProcessorValueTreeState table(nullptr, "Filter", createLayout());
const sonore::ParamInfo* infos = table.parameterInfos();
const bool ok = table.numParameters() == 3 && std::strcmp(infos[0].id, "cutoff") == 0 &&
infos[0].defaultValue == 1000.0f && infos[1].stepCount == 3;
for (int i = 0; i < table.numParameters(); ++i)
std::printf("%d %-8s %g..%g\n", i, infos[i].id, infos[i].minValue, infos[i].maxValue);
return ok ? 0 : 1;
}