DSP
← devlog

Niner v0.7.6: in-plugin MIDI Learn for relative encoders

Niner v0.7.6 went out today. The headline is in-plugin MIDI Learn for relative encoders — right-click a knob, hit Learn, wiggle the controller, the binding sticks and survives DAW restart. Bundled in the same release: sample-accurate MIDI dispatch, a Linux launcher rewrite that auto-detects USB MIDI controllers, and a UI polish pass with the new “9” header lockup.

What was originally planned as a verify-only release grew once I started testing the BeatStep against the new dispatch path and three small bugs surfaced. I’d rather hold a release a few days than ship a known-broken MIDI path.

In-plugin MIDI Learn for relative encoders

Right-click any knob. The context menu now offers MIDI Learn and MIDI Forget. Hit Learn, move a control on your hardware, the binding takes. CC and NoteOn are both supported. Bindings serialize via #[persist] so they survive plugin reload, DAW restart, and project reopen.

The piece that actually needed thinking was relative encoders. An Arturia BeatStep MK1, depending on its mode, sends one of three different things for “I turned the knob”: Absolute (0–127), BinaryOffset (0x40 ± delta), or Centered (0x40 is the rest position; values above/below mean increment/decrement). If the plugin assumes Absolute and the controller sends Centered, every encoder click jumps the knob to ~50% and you spend the rest of the session fighting the math.

Niner now auto-detects which of the three encodings is in use, per binding, when Learn fires. The detection result locks once and subsequent edits go through the resolved decoder. There’s a manual override in the right-click menu for the edge case where an absolute pot’s first capture lands on raw 1, 63, 65, or 127 — those values are valid in all three encodings and the auto-detect can’t tell them apart from a single sample.

A more robust “wiggle to learn” pass that captures 4–6 values during bind is in the v0.8.0 backlog. The current single-capture approach is the 80% solution; the override exists for the other 20%.

Sample-accurate MIDI dispatch

This one’s invisible until you A/B it, then it’s hard to unhear. Before v0.7.6, MIDI events were applied at buffer boundaries. With a 512-sample buffer at 48 kHz, that’s up to ~10.7 ms of slop between an encoder tick and the parameter actually moving — fine for chord changes, audible as smearing on a 16th-note pattern.

The new dispatch path queues each incoming MIDI event with its frame_offset and applies it at the matching sample inside process(). Latency from event to audio is now bounded by the buffer size itself, not the buffer size plus a buffer.

Same release fixed a knob-readback bug where modulating a parameter from a MIDI binding showed the post-modulation value in the GUI instead of the unmodulated user-set value. Readback now uses unmodulated_plain_value, so what you see in the knob is what you dialed in, even while a controller is wiggling the live value underneath.

JACK / ALSA launcher rewrite

The Linux launcher (niner-launch) now scans aconnect -i at startup. If it finds a known MIDI controller, it passes --backend alsa --midi-input "<device name>" automatically and you get MIDI without touching CLI flags.

ALSA is the new default because a JACK-stuck-state under PipeWire — where the standalone wedges its JACK port and won’t reconnect after a sleep/wake cycle — was the most common bug report from v0.7.5. ALSA sidesteps it. The JACK path is still there for users who specifically want it: NINER_FORCE_BACKEND=jack niner-launch.

LED display: full alphabet on 7-seg

The seven-segment display now renders the whole alphabet. Lowercase folds where the segment math allows; the awkward letters get best-approximation fallbacks: M → n, V → u, W → u, X → h, K → h. It isn’t pretty, but it reads, and previously those characters dropped out of the display entirely.

Header + chassis polish

The header lockup carries a new “9” badge SVG to the left of the NINER wordmark. Bone-cream #F4F1EA replaces hard white in tints across the panel — image tints flipped to Color32::WHITE identity, because using the bone token as a tint over an already-bone source double-darkens the result. Hex-screw shadows got inverted in the same pass; the screw heads were rendering with the highlight on the bottom, which read as wrong-direction lighting against the chassis bake.

Layout editor picked up undo/redo: Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z plus visible buttons, 64-entry stack, capture-on-pointer-up so dragging a knob accumulates one undo entry per drag instead of one per pixel.

Small label cleanup: BWbandwidth. The two-letter abbreviation read as “black/white” once for someone in beta, and once was enough.

A multisize app icon set landed alongside the SVG source pipeline (assets/icon/, assets/source/) so the desktop launcher and .desktop entry now have proper icons at every size the WM might ask for, instead of the stretched 128 px fallback.

What’s deferred

For honesty’s sake, the v0.8.0 pile in priority order:

  • MIDI Clock sync (0xF8 tick, 0xFA start, 0xFC stop). After this lands, BeatStep PLAY = niner follows. Highest-ROI item on the list.
  • BoolParam MIDI Learn — extend right-click → Learn to the LIM, CLAP, and DJ Filter PRE toggles. Currently only Param floats are bindable.
  • Sequencer step MIDI Learn — bind 16 pads to 16 step toggles. Combined with MIDI Clock and BoolParam, the BeatStep becomes a complete remote.
  • Wiggle-to-learn auto-detect — the 4–6-value pass mentioned above.

Where to get it

  • AUR: ninerpkgver=0.7.6
  • GitHub Release: v0.7.6 — VST3 + CLAP for Linux, Windows, and macOS (Apple Silicon + Intel), with SHA256SUMS.txt alongside
  • VirusTotal scans clean (0/60+) on all four bundles; permalinks live on the plugin page

Other Niner devlogs