DSP
← devlog

The knob I removed from my plugin

A producer dropped me a note about Niner last week:

VEL seems to do nothing at all. Please double-check and potentially remove it entirely.

They were right. I removed it.

What VEL was supposed to do

Niner’s master row had four knobs: DECAY, DRIFT, VEL, VOL. VEL was velocity sensitivity — a scale factor that told the engine how much MIDI note velocity should modulate the output level. Velocity 127 would hit at 0 dB; at VEL=0, velocity was ignored; at VEL=1.0, it mapped 1:1.

The math was correct. The code path was fine. I have unit tests on it.

What VEL actually did

Almost nothing, because Niner is mostly used as a standalone and a sequencer. The TEST button hardcodes velocity to 1.0. The internal 16-step sequencer hardcodes velocity to 1.0. The BOUNCE button hardcodes velocity to 1.0. The only context where VEL mattered was “the plugin is loaded in a DAW, driven by incoming MIDI notes with real variable velocities”. Solo-producer workflows, in other words, are ~5% of my mental model of how people actually use this thing.

So for every producer running the standalone to design a kick — which is the dominant workflow I hear about — VEL was one of four knobs on the master row that did genuinely, observably nothing. Turn it up, turn it down, kick sounds identical.

That’s not a knob. That’s a trap.

Why I nearly kept it

When the note came in, my first instinct was to defend it. “It works in DAW contexts, the test coverage is there, producers who route MIDI to it will miss it.” All of which is true and none of which is a good reason to leave a knob in.

The voice pillar I try to hold the studio to is honest over hype. Admit tradeoffs. Never “revolutionary”. The trap version of that pillar is treating it as something you apply to marketing copy while quietly keeping every shipped feature no matter how vestigial. Honest-over-hype has to reach into the UI or it’s not real.

A knob the user touches and nothing audibly changes is dishonest. The knob implies a control. The control implies a dimension of the sound you can shape. When the dimension turns out to be “only if you are doing a thing most of you are not doing”, the contract is broken.

What removing it cost

More than I expected. VEL had six touch points:

  • params.rs — the parameter definition, normalized range, default.
  • engine.rsKickEngine applied the scale factor during trigger.
  • ParamSnapshot — the struct that passes live param state to the bounce renderer.
  • UiToDsp — message type for GUI → audio-thread parameter updates.
  • Factory presets — every one had a vel: 1.0 entry in its serialized form.
  • The master row layout — four knob positions on a deliberately tight grid.

Stripping it was maybe forty lines of code and a schema migration for presets. The interesting bit was the layout:

Three knobs where four used to be means one slot is empty. You have three options: leave the empty slot (looks like a placeholder, users will wonder what goes there), recompute spacing (looks different from last version, confuses repeat users), or expand something else into the freed space (best option, most work).

I expanded the waveform display one column. The waveform is the kick preview that lives across the top of the plugin; it was already the most important visual element, so widening it by ~16% made the thing it was trying to show easier to see. The tradeoff is that the master row now sits slightly right of perfect-center relative to the voices row below it. You would only notice if you were looking for it.

The preset-migration bit

Every factory preset had this line in its JSON:

{ "decay": 0.62, "drift": 0.15, "vel": 1.0, "vol": 0.85 }

When I removed the field from params.rs, serde would barf on load — unknown field vel. Two options again: tag the struct with #[serde(ignore_unknown_fields)], or migrate the presets.

I migrated the presets. The #[serde] flag is cheap, but it hides real errors: a typo in a future param name would silently load with a default instead of failing loudly. A one-time migration script, run once at build time over the factory preset directory, is boring and correct. A user’s saved custom presets will have the field rejected when they load — the plugin logs a warning and drops the unknown key, no crash. That’s the right fallback for user data we can’t rewrite.

What I’m taking from this

Two things stuck.

One: the knob I defended least was the one most worth removing. Every instinct said “keep it, it works, tests pass”. The producer who pinged me had no stake in the engineering history — they were pattern-matching on “turned a knob, heard nothing” and correctly concluded the knob was broken. They were using the software the way users do, which is a reliable filter for features that look correct and aren’t.

Two: if the voice pillar is honest over hype, the UI is where it’s tested, not the About page. Five About-page paragraphs about honest tradeoffs mean nothing if a knob on the master row lies about what it does. The knob was the post about the knob.

Niner is in beta, free, open source. The master row has three knobs now. If you find another one that doesn’t do what its label says, send it over — I will probably remove that too.