# Sound-Quality Conventions for pack authors

The Sound-Quality Conventions Welle (PR #196, v0.51.0) shipped a set of orthogonal Faust DSP conventions: param-level smoothing, velocity-responsive scaling, multi-voice unison with per-voice drift, and kind-level Coloration (tape / tube / transistor_drive / bus_glue). For graph authors they're knobs; for **pack authors** they're declarative metadata fields on a kind that auto-wrap your `toBox`.

This section is the auto-wrap reference. Most kinds use 0–2 of these conventions and skip the rest.

## Per-param smoothing — `smoothing_class`

Add `smoothing_class: 'volume' | 'cutoff' | 'pitch' | 'drive' | 'mix'` to any `ParamDef`. The param's resolved expression gets wrapped in `si.smooth(ba.tau2pole(τ))` with a class-appropriate τ:

| Class | τ | When to use |
|---|---|---|
| `volume` | 0.005 s | Per-cycle gain changes — Pan, Gain, Send level |
| `cutoff` | 0.03 s | Filter cutoffs, env-mod amounts — bigger jumps |
| `pitch` | 0.001 s | Detune, transpose — perceptually quick |
| `drive` | 0.05 s | Saturator drive, distortion drive — perceptually slow |
| `mix` | 0.03 s | Dry/wet mix, feedback amount |

The wrap is invisible to your `toBox` — `param('cutoff')` in a smoothing-classed kind already emits the smoothed value. You don't write the `si.smooth(...)` call yourself.

Example: the `lp` kind sets `smoothing_class: "cutoff"` on its cutoff slider; the picker preview + the engine both apply 0.03 s tau without further configuration.

## Velocity-responsive params — `velocity_responds`

Add `velocity_responds: <0..1>` to any `ParamDef`. The param's resolved expression gets multiplied by `1 + (velocity - 1) * velocity_responds`, where `velocity` is the per-voice MIDI velocity (1.0 = no scaling, 127/127 in normalised form).

- `velocity_responds: 0` (default) — no velocity scaling. The param is independent of how hard the key was struck.
- `velocity_responds: 1` — fully velocity-controlled. Hard hit = full param value, soft hit = nothing.
- `velocity_responds: 0.3` (typical) — softer hits darken / dampen the param by ~30%, hard hits hit full value. Common on filter cutoffs and effect mix amounts.

The wrap sits **inside** the smoothing wrap if both are set (smoothing applies after velocity-scaling, so quick velocity-driven ramps still smooth).

Example: the `lp` kind sets `velocity_responds: 0.3` on its cutoff so a soft note lands at ~70% cutoff, a hard note at full — a classic "darken at low velocity" feel.

## Drift wrap — `drift_param`

Add `drift_param: 'detune' | 'cutoff' | 'none'` at the **kind level** (not per-param). When the Output node enables Unison (voices > 1) AND its `drift_amount` is non-zero, the named param gets a slow per-voice random offset injected — different per voice, slow rate (~5 Hz LFO range), no audible chorus but breaks the "all voices identical" sterility.

- `'detune'` — the wrap shifts the param by ±N cents based on the drift LFO. Useful on `osc`, `saw_polyblep`, etc.
- `'cutoff'` — the wrap shifts the param by ±N% based on the drift LFO. Useful on `lp`, `hp`, `moog_vcf`.
- `'none'` (default) — no drift wrap. Kind plays identical across voices.

The wrap only activates when the DSP is poly + the Output node's drift_amount > 0 — single-voice DSPs see no effect.

## Multi-voice unison — kind-level support

The Output node's Unison config (voices / detune_cents / stereo_spread) is consumed at the graph-level by `faustGen` (it wraps the whole DSP in `par(i, N, voice(i))`). Individual kinds don't usually need to participate — but if your kind cares about per-voice replication (e.g. you want different RNG seeds per voice), the substitution env exposes:

- `polyMode: boolean` — true when the DSP is poly.
- `voiceIndex: Box` — the loop iteration variable, a `(0, 1)` Box that emits `i` literally.

Use via the `defs` clause to make the i-reference explicit:

```json
"defs": {
  "voice_seed": "param('seed') * (voiceIndex + real(1))"
},
"outputs": {
  "out": "lib('no', 'noise', def('voice_seed'))"
}
```

(Hypothetical — most kinds don't need this. Look at `midi_note` in the `faust` pack for the canonical real example: detune-shift via the par-bound `i` symbol.)

## Coloration Kinds — the `faustwave-conventions` pack

The Coloration suite (`tape`, `tube`, `transistor_drive`, `bus_glue`) lives in `faustwave-conventions` rather than the `faust` pack because they're **opinionated** — they're not stdfaust wrappers, they're FaustWave's specific recipes for warmth + cohesion. If your pack wants to surface a custom saturator, follow this template:

- Single audio in, single audio out (`(1, 1)` shape).
- One or two params, all with `smoothing_class: 'drive'` or `'mix'`.
- A clear sonic identity — don't ship "yet another tanh".
- Pack id starts with your handle prefix if Hub (`mypack-`); the `faustwave-` prefix is reserved for FaustWave-team packs.

Coloration Kinds ARE composed (multiple stdfaust primitives combined), but they're the **exception** to the bundled-vs-Hub rule — see § 1 on classification.

## Picker bucket — `category`

The `category` field maps your kind to a picker section. Canonical buckets that the picker shows top-row Quick-Buttons for:

`Sources`, `Oscillators`, `Instruments`, `Modulation`, `Filters`, `Effects`, `Math`, `Time`, `Output`, `Analyzers`, `Spatial`, `Triggers`, `Vintage`, `Vocal`.

Anything outside that list still works — it lands alphabetically after the canonical set. But if your kind fits a canonical bucket, use it; if it's genuinely a new domain, mint a new category + own it as a pack convention.

## Visual accent — nothing to declare

There is no `accent` field. A kind's colour is **derived from its ports**: the node's dot and frame take the signal kind of its first output — or, for a sink with no outputs, its first input.

| Port signal | Node colour |
|---|---|
| `audio` | the audio signal colour — the same one its cables are drawn in |
| `control` | the control signal colour |
| `trigger` | the trigger signal colour |

That is the whole rule, and it exists because the old one could not hold. `accent` was a six-colour palette you picked by hand, documented right here as "also the colour of any cable plugged into the kind's audio out" — two facts kept in sync by convention. They drifted apart the moment audio got a signal colour of its own: cables moved, hand-declared accents did not, and a node's dot said one thing while the cable leaving it said another.

Declaring the colour separately from the signal is now impossible, so it cannot drift again. Packs that still carry an `accent` field keep loading — the value is ignored.

## Putting it together — a saturated lowpass

```json
{
  "kind": "saturated_lp",
  "label": "Saturated Lowpass",
  "category": "Filters",
  "description": "2nd-order lowpass with a tanh drive stage in front — vintage tube preamp + tone-shaping in one node.",
  "drift_param": "cutoff",
  "bypass": { "input": "in", "output": "out" },
  "inputs": [
    { "id": "in", "label": "in", "kind": "audio" },
    { "id": "cutoff_in", "label": "cutoff", "kind": "control" }
  ],
  "outputs": [{ "id": "out", "label": "out", "kind": "audio" }],
  "params": [
    { "type": "hslider", "id": "drive", "label": "Drive", "init": 1.0,
      "min": 1, "max": 4, "step": 0.01, "style": "knob",
      "smoothing_class": "drive" },
    { "type": "hslider", "id": "cutoff", "label": "Cutoff", "init": 1000,
      "min": 20, "max": 20000, "step": 1, "unit": "Hz", "scale": "log",
      "style": "knob", "smoothing_class": "cutoff", "velocity_responds": 0.3 }
  ],
  "toBox": {
    "defs": {
      "driven": "lib('ma', 'tanh', times(input('in'), param('drive')))"
    },
    "outputs": {
      "out": "lib('fi', 'lowpass', int(2), param('cutoff'), def('driven'))"
    }
  }
}
```

Reads top-down: drive is smoothed (5 ms drive class), cutoff is smoothed + velocity-scaled + drift-wrapped (in poly + unison), input runs through tanh-drive, then through 2nd-order lowpass at the smoothed cutoff. All five Sound-Quality conventions composed without a single `si.smooth()` or velocity-multiply in the `toBox` — the metadata fields do the work.
