0020 — Plugin tiers are fixed by MEASURED latency; LV2 ships as tiered subpackages

Status: Accepted (built, including the tiered RPM packaging — 2026-07-29 note below) Date: 2026-07-15

Context

A live console must never host a latency-eater (linear-phase EQ, look-ahead limiter, FFT denoiser, long convolution reverb). The catalog scans ~610 LV2 plugins, and the RPM originally shipped none. The obvious signal — the plugin's declared lv2:latency port — cannot be trusted: a measured round-trip pass over all 610 found 12 plugins that declare zero latency but actually delay the signal (two SWH IIR crossovers by ~99 ms), and the inverse (SWH artificialLatency declares 120000 frames, delays 0).

Decision

  • Measure, don't declare. Every plugin's round-trip latency is measured offline (lilv run() + a unit impulse + onset detection, benchmark.py --measure). The measured figure fixes the tier: live (≤ 5 ms) / studio (> 5 ms). A plugin that can't be measured (instrument/no-audio-in, silent under defaults, won't instantiate) is quarantined unclassified — never auto-live.
  • Two axes. Latency fixes the tier (automatic); curation splits recommended vs extra within a tier (curation.ts ∪ the harvested zynthian effects set + a curator override list; an override may set any tier except promote to live from a non-live measured tier — so a curator can rescue an unmeasurable-but-known-good effect to studio case-by-case, but a latency-eater can never reach the live path).
  • Ship as four RPM subpackages — openmixer-plugins-{live,live-extra,studio,studio-extra} — with dependencies auto-derived (per-plugin owning RPM via rpm -qf → INDEX.json → generated plugin-deps.inc). The meta package Recommends: openmixer-plugins-live only.
  • Catalog stored per-package (data/plugins/<rpm>.json, sorted) so a plugin-package update is one reviewable file diff.

Consequences

  • A live install physically cannot pull in a studio latency-eater; a 99 ms crossover can never slip into the live set on a false declaration.
  • The committed catalog is a reproducible build input (no scan needed in a clean chroot).
  • -live is a Recommends, not a Requires: the native EQ/gate/comp/delay/reverb (ADR 0021) make the console fully functional with zero LV2 plugins.
  • Same "measure the hardware/reality, don't trust a declaration" principle as ADR 0018 (clock).

Note — 2026-07-29: packaging landed; a third suitability axis has since appeared

The core of this ADR is implemented verbatim and is worth trusting: the 5 ms live boundary (LIVE_MAX_MS = 5 in packages/catalog/src/tier.ts), the quarantine of the ambiguous rather than a guess (tierForBounds in the same file), and the curator-override rule that overrides may demote but never promote to live (resolveTier in packages/catalog/src/curation-tier.ts).

Two things have moved since.

Packaging is done, not "in progress". The four subpackages are generated by packages/catalog/tools/gen-plugin-deps.mjs, the generated packaging/rpm/plugin-deps.inc is committed and pulled in by the spec's own %include %{omx_plugin_inc} directive, and Recommends: %{name}-plugins-live appears exactly as specified. The per-package catalogs are packages/catalog/data/plugins/*.json plus INDEX.json.

"Two axes" is now three. This ADR says latency fixes the tier and curation splits recommended-vs-extra within it. Measured per-plugin CPU cost has since joined them as a first-class dimension — the module doc comment in packages/catalog/src/cpu-cost.ts calls itself "the third suitability dimension, alongside latency and topology", fed by tools/benchmark.py --cost with provenance committed at packages/catalog/data/cpu-cost-provenance.json, alongside destination-suitability.ts and family-standing.ts. Someone scoping catalog work off this ADR alone will under-scope it.

The "~610 LV2 plugins" figure in Context is the size of the scan at the time; the catalog now holds 958 entries. The "12 of 610 declare zero latency but actually delay the signal" measurement is a dated finding and reads correctly as one.