Search guides and the APICtrl K
  1. Docs
  2. Guides

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.

C++Compiled and run in CI
#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, getDouble or getBool. 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.

C++Compiled and run in CI
#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;
}
↑↓ to move↵ to openEvery public name in the headers, and every guide