ParamMetaData Reference
Repo: surge-synthesizer/sst-basic-blocks, also vendored into consumers at
libs/sst/sst-basic-blocks/. Paths below are relative to that root.
Header: include/sst/basic-blocks/params/ParamMetadata.h
Tests: tests/param_tests.cpp
cmake --build $B --target sst-basic-blocks-test # $B is your configure dir
# from a consuming project the binary lands under its libs/ subtree; find it with:
find $B -name 'sst-basic-blocks-test' -type f
ParamMetaData is metadata-only — it never stores a value. All APIs take the current value as an argument. It is a builder-pattern struct: chain .withXxx() calls to configure, then call valueToString, valueFromString, modulationNaturalToString, etc.
Core Types
enum Type { FLOAT, INT, BOOL, NONE };
FLOAT is the default. INT rounds values on display. BOOL threshold-tests at 0.5.
Display Scales
Set by the withXxxFormatting methods. Determines the formula used in valueToString / valueFromString.
| Scale | Formula | Set by |
|---|---|---|
LINEAR |
svA * val + svB |
withLinearScaleFormatting(units, scale=1, offset=0) |
A_TWO_TO_THE_B |
svA * 2^(svB*val + svC) + svD |
withATwoToTheBFormatting(A, B, units) |
SCALED_OFFSET_EXP |
(exp(svA + val*(svB-svA)) + svC) / svD |
withScaledOffsetExpFormatting(A,B,C,D,units) |
CUBED_AS_DECIBEL |
20*log10(val³ * svA) |
via asCubicDecibelAttenuation() etc. |
LOGARITHMIC |
svA * log_svB(val) + svC |
withLogarithmicFormating(units, scale, basis, offset) |
UNORDERED_MAP |
lookup discreteValues[(int)round(val)] |
withUnorderedMapFormatting(map) |
MIDI_NOTE |
note name from int (C4, A#3…) | withMidiNoteFormatting() / asMIDINote() |
Sign convention for SCALED_OFFSET_EXP: the stored constant svC is added in the forward formula: (exp(...) + svC) / svD. The inverse uses svD*r - svC. A negative svC (like −2 in as25SecondExpTime) subtracts in the display. This has been a source of sign bugs — always follow valueToString which is correct.
Alternate Scale (ms/s, Hz/kHz thresholds)
Attach a secondary unit that kicks in when the displayed value crosses a threshold:
// Show ms when value < 1 s, s otherwise
.withDisplayRescalingBelow(1.f, 1000.f, "ms")
// Show kHz when value > 1000 Hz
.withDisplayRescalingAbove(1000.f, 0.001f, "kHz")
// Convenience: s/ms threshold
.withMilisecondsBelowOneSecond() // cutoff=1, rescale=1000, dp=1, ms is default typein
// Disable alternate scale
.withoutDisplayRescaling()
// Make alternate unit the default when parsing a bare number
.withAlternateAsDefaultFromStringUnit(true)
// Override decimal places in the alternate range
.withAlternateDecimalPlaces(1)
Works for LINEAR and A_TWO_TO_THE_B. Also works for SCALED_OFFSET_EXP but the modulation display
code has special handling — see the bug notes below.
FeatureState
Passed to valueToString / valueFromString / modulationNaturalToString to describe the current
runtime state of optional features:
FeatureState fs;
fs = fs.withHighPrecision(true); // +4 decimal places
fs = fs.withExtended(true); // apply exA/exB: val = exA*val + exB
fs = fs.withAbsolute(true); // absolute mode (client interprets)
fs = fs.withTemposync(true); // display as beat fraction (e.g. "1/4 note")
fs = fs.withNoUnits(true); // suppress unit suffix
fs = fs.withModulationClamped(false); // allow modulation past param range
These return a copy — always use the return value. fs.withAbsolute(true); on its own
compiles and does nothing.
Modulation Display
// Returns ModulationDisplay or nullopt
auto md = p.modulationNaturalToString(
float naturalBaseVal, // current param value in natural units
float modulationNatural, // modulation depth in natural units
bool isBipolar, // +/- vs unidirectional
FeatureState fs = {}
);
struct ModulationDisplay {
std::string value; // delta with units, e.g. "+3.57 s"
std::string summary; // brief, e.g. "+/- 3.57 s" or "-/+ 3.57 s"
std::string baseValue; // valueToString(naturalBaseVal)
std::string valUp; // display of base + modulation
std::string valDown; // display of base - modulation (bipolar only)
std::string changeUp; // numeric delta up (no units)
std::string changeDown; // numeric delta down (no units, bipolar only)
std::string singleLineModulationSummary; // "valDown < baseValue > valUp"
};
// Parse a delta string back to a natural modulation depth
auto mv = p.modulationNaturalFromString(std::string_view delta, float naturalBaseVal, std::string &err);
Supported display scales for modulation: LINEAR, A_TWO_TO_THE_B, SCALED_OFFSET_EXP, CUBED_AS_DECIBEL. Others return nullopt.
Alternate units in modulation (SCALED_OFFSET_EXP only): when the delta is small enough to fall
in the alternate-unit range (e.g. < 1 s), the value and summary fields use the alternate unit
(e.g. "ms"). valUp / valDown always come from valueToString which handles this independently.
singleLineModulationSummary format differs by scale:
LINEAR/A_TWO_TO_THE_B/CUBED_AS_DECIBEL:valDown unit < baseValue > valUp unit(valUp/valDown are raw numbers, unit appended by the format string)SCALED_OFFSET_EXP:valDown < baseValue > valUp(valUp/valDown come fromvalueToStringand already include their units)
Preset Builders
| Method | Type | Range | Scale | Notes |
|---|---|---|---|---|
asPercent() |
FLOAT | 0..1 | LINEAR ×100 % | quantized 0.1 |
asPercentBipolar() |
FLOAT | −1..1 | LINEAR ×100 % | |
asPercentExtendableToBipolar() |
FLOAT | 0..1 | LINEAR | extendable ×2−1 |
asDecibelWithRange(lo,hi,def) |
FLOAT | lo..hi | LINEAR dB | |
asDecibel() |
FLOAT | −48..48 | LINEAR dB | |
asDecibelNarrow() |
FLOAT | −24..24 | LINEAR dB | |
asLinearDecibel(lo,hi) |
FLOAT | lo..hi | LINEAR dB | multiplicative mod |
asCubicDecibelAttenuation() |
FLOAT | 0..1 | CUBED_AS_DECIBEL | val=1 → 0dB; multiplicative mod |
asCubicDecibelUpTo(maxDb) |
FLOAT | 0..N | CUBED_AS_DECIBEL | max > 0dB |
asPan() |
FLOAT | −1..1 | LINEAR % | custom L/C/R labels |
asMIDIPitch() |
FLOAT | 0..127 | LINEAR semitones | integer quantized |
asMIDINote() |
INT | 0..127 | MIDI_NOTE | note name display |
asAudibleFrequency() |
FLOAT | −60..70 | A_TWO_TO_THE_B 440Hz | ALLOW_MIDI_NOTENAMES |
asSemitoneRange(lo,hi) |
FLOAT | lo..hi | LINEAR semitones | tuning fractions |
asLfoRate(lo,hi) |
FLOAT | −7..9 | A_TWO_TO_THE_B 1Hz | temposyncable |
asLog2SecondsRange(lo,hi,def) |
FLOAT | lo..hi | A_TWO_TO_THE_B 1s | + ms below 1s |
asEnvelopeTime() |
FLOAT | −8..5 | A_TWO_TO_THE_B 1s | = asLog2SecondsRange |
as25SecondExpTime() |
FLOAT | 0..1 | SCALED_OFFSET_EXP | 0→0s, 1→25s; + ms below 1s |
asOnOffBool() |
INT | 0..1 | UNORDERED_MAP | "Off"/"On" |
asStereoSwitch() |
INT | 0..1 | UNORDERED_MAP | named "Stereo" |
Features Flags
Set with .withFeature(Features::X) or the named helpers:
| Flag | Helper | Effect |
|---|---|---|
BELOW_ONE_IS_INVERSE_FRACTION |
— | A_TWO_TO_THE_B values < 1 display as "1/N" (used by LFO rate multiplier) |
ALLOW_FRACTIONAL_TYPEINS |
— | Parses "3/2" as ratio in LINEAR and A_TWO_TO_THE_B |
ALLOW_TUNING_FRACTION_TYPEINS |
— | Parses "3/2" as 12*log2(3/2) semitones |
ALLOW_MIDI_NOTENAMES |
.withSemitoneZeroAt440Formatting() |
Parses/displays note names ("A4") in A_TWO_TO_THE_B |
SUPPORTS_MULTIPLICATIVE_MODULATION |
.withSupportsMultiplicativeModulation() |
Client hint for × vs + mod |
MULTIPLICATIVE_MODULATION_OFF_BY_DEFAULT |
.withMultiplicativeModulationOffByDefault() |
Client hint |
FLOAT_ALWAYS_QUANTIZES |
.withFloatAlwaysQuantizes() |
Client hint |
valueToAlternateString
Returns a secondary representation (or nullopt). Currently implemented only for A_TWO_TO_THE_B
with ALLOW_MIDI_NOTENAMES (i.e. asAudibleFrequency()):
auto s = p.valueToAlternateString(val, fs);
// val=0 → "A4" (exact integer semitone: no prefix)
// val=0.5 → "~A#4" (non-integer: ~ prefix)
// val=12 → "A5"
ShortCircuit's attachment code calls this to offer a note-name readout alongside the Hz value.
SCALED_OFFSET_EXP: the traps
This scale has produced more bugs than the rest of the file combined. If you are touching
it, or writing a parameter that uses as25SecondExpTime, know these:
The
svCsign. The forward formula adds it:(exp(...) + svC) / svD. The inverse issvD*r - svC. A negativesvCtherefore subtracts in the display. FollowvalueToString, which is correct, rather than reasoning from the constant's sign.Alternate units apply to the deltas independently. In
modulationNaturalToString, the up-delta and down-delta each choose their own unit, so a modulation that crosses the 1-second threshold gets "ms" on one side and "s" on the other. Getting the two units crossed is easy and produces a plausible-looking wrong string ("25.00 ms" for a 25-second depth).valUp/valDownalready carry their units, because they come fromvalueToString.valueandsummarydo not. Appending a unit to the first pair double-prints it.Bipolar summary polarity keys off the up delta, not the down one, to decide between
"+/- X"and"-/+ X".
Every one of these has a regression test in the 25 Second Exp case. If you change this
scale, run that test first.
Test Coverage Reference
Tests live in param_tests.cpp. Key test cases:
tests/param_tests.cpp. Run grep 'TEST_CASE' tests/param_tests.cpp for the current list;
these are the ones worth knowing about before you change behaviour:
| Test case | Guards |
|---|---|
25 Second Exp |
Every SCALED_OFFSET_EXP trap above. Run this first when touching that scale. |
Modulation Natural To String |
Numeric modulation for LINEAR / A_TWO_TO_THE_B / CUBED_AS_DECIBEL, and modulationNaturalFromString round-trips |
Alternate Scales Above and Below |
ms/s and Hz/kHz threshold switching |
Two to the X Formatting |
AB / ABC / ABCD / AeB / AeBCD / OBXF log variants |
Temposync type In |
Beat-fraction display and valueFromTemposyncNotation |
Temposync ZERO_ONE flavor dispatch |
Which temposync flavour a 0..1 parameter resolves to |
Extended Float Parameter |
withExtendFactors, simple and AB |
FeatureState withAbsolute |
The copy-not-mutate contract |
Logarithmic Display Scale |
Including −inf |
MIDI Note Display Scale, Value To Alternate String |
Note-name display, exact vs ~ approximate |
Unordered Map Display, Below One Is Inverse Fraction Feature |
Map lookup, 1/N display |
BOOL Type Normalization, Custom Min Max and Default, Parameter Polarity |
Normalization edges |
Quanta-mode metadata is allocation-free |
Realtime safety. Quantization metadata must not allocate — this is asserted, so a std::string or container added to that path fails the build's intent, not just style. |
INT type normalization asymmetry
naturalToNormalized01 for INT type uses a simple linear map by default (pass
useSurgeIntConvention=true for the legacy Surge formula). normalized01ToNatural for INT always
uses the Surge convention. These are not inverses of each other — this is intentional for
backward compatibility. Do not rely on a round-trip through INT normalization.
Quantization
.withQuantizedInterval(interval) // snap to multiples of interval
.withIntegerQuantization() // = withQuantizedInterval(1)
.withQuantizedStepCount(n) // divide [min,max] into n steps
p.quantize(val) // apply quantization; handles CUBED_AS_DECIBEL and SCALED_OFFSET_EXP specially
p.supportsQuantization()
CUBED_AS_DECIBEL quantization works in dB space. SCALED_OFFSET_EXP quantization works in the
display-value space (rounds to nearest 0.1, 1, 10 depending on magnitude).