# Sst Param Metadata

> Reference for ParamMetaData in sst-basic-blocks. Covers display scales, modulation display, string conversion, FeatureState flags, preset builders (asPercent, asEnvelopeTime, as25SecondExpTime…), quantization, and the test suite.

- Skill: `baconpaul/sst-param-metadata` (Agent Skill)
- Install (CLI): `npx skillmds@latest add baconpaul/sst-param-metadata`
- Raw SKILL.md: https://api.skillmd.com/api/skills/baconpaul/sst-param-metadata/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: baconpaul (https://skillmd.com/u/baconpaul)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/baconpaul/sst-param-metadata

---


# 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`

```bash
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

```cpp
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:

```cpp
// 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:

```cpp
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

```cpp
// 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 from `valueToString` and 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()`):

```cpp
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:

1. **The `svC` sign.** The forward formula adds it: `(exp(...) + svC) / svD`. The inverse
   is `svD*r - svC`. A negative `svC` therefore *subtracts* in the display. Follow
   `valueToString`, which is correct, rather than reasoning from the constant's sign.

2. **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).

3. **`valUp` / `valDown` already carry their units**, because they come from
   `valueToString`. `value` and `summary` do not. Appending a unit to the first pair
   double-prints it.

4. **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

```cpp
.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).
