Skip to content
Back to skills

Lv2

ASecurity

LV2 format adapter for Pulp — the Turtle manifest the build emits by asking the module to describe itself, port indices as a saved-session wire format with one shared layout, host transport arriving as a time:Position atom on the MIDI port, the optional buf-size feature that is a hint and not a guarantee, state:interface versus control ports, and the real-time rules run() has to keep.

  • 22 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 24, 2026
researchrustgoc++expresstestinggitapi

Works with

  • api

Security analysis

A100/100

Scanned September 30, 2026

npx -y skills add danielraffel/pulp --skill lv2 --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Lv2?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Lv2
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/danielraffel-lv2/badge)](https://www.skillsdirectory.com/skills/danielraffel-lv2)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: lv2
description: LV2 format adapter for Pulp — the Turtle manifest the build emits by asking the module to describe itself, port indices as a saved-session wire format with one shared layout, host transport arriving as a time:Position atom on the MIDI port, the optional buf-size feature that is a hint and not a guarantee, state:interface versus control ports, and the real-time rules run() has to keep.
---

# LV2 Skill

Use this when touching Pulp's LV2 adapter, when answering "what does a Pulp
plugin look like to Ardour / Carla / Qtractor", or when an LV2 bundle behaves
in a way the other adapters do not. LV2 is Pulp's Linux-native format and the
only one whose host-facing contract is **RDF text generated by the plugin**
rather than a C++ or C interface the compiler checks. That single difference
is the source of most of what follows.

LV2 is `experimental` on Linux only in `docs/status/support-matrix.yaml`. Read
that file's `formats:` and `format_limitations.lv2` before you promise anybody
anything — it is the canonical list of what the adapter does and does not do,
and this page deliberately does not restate it so the two cannot drift.

## When to use

- Editing `core/format/src/lv2_adapter.cpp` or either LV2 header.
- Adding an LV2 extension (`state`, `time`, `buf-size`, `options`, `worker`,
  `patch`, `ui`, …) or changing a port.
- A host will not load, will not discover, or mis-wires a Pulp `.lv2` bundle.
- Wiring transport, block size, or plugin-owned state on the LV2 path.
- Cross-checking LV2 against CLAP/VST3/AU during a parity fix — the four
  adapters are each other's oracle, and LV2 is usually the one missing a piece.

## Where things are

| Role | Path |
|---|---|
| Turtle generation (`generate_plugin_ttl`, `generate_manifest_ttl`) | `core/format/src/lv2_adapter.cpp` |
| Instance struct, URID cache, port pointers | `core/format/include/pulp/format/lv2_adapter.hpp` |
| `instantiate` / `connect_port` / `run` / `cleanup` + `PULP_LV2_PLUGIN` | `core/format/include/pulp/format/lv2_entry.hpp` |
| Bundle build rule (`_pulp_add_lv2`) | `tools/cmake/PulpPluginFormats.cmake` |
| Scaffolded per-plugin entry | `tools/templates/{gain,effect,instrument}/lv2_entry.cpp.template` |
| Shared over-long-block clamp | `core/format/include/pulp/format/max_block_contract.hpp` |
| Shared transport → `ProcessContext` mapper | `core/format/include/pulp/format/adapter_boundary.hpp` |
| Host side (Pulp *loading* an LV2 plugin) | `core/host/src/plugin_slot_lv2.cpp`, `core/host/src/scanner.cpp` — see the `hosting` skill |
| Tests | `test/test_lv2_adapter.cpp`, `test/test_lv2_rt.cpp`, `test/test_lv2_host_discovery.cpp` |

## The bundle describes itself, and the module is what describes it

`_pulp_add_lv2` emits `manifest.ttl` and `<binary>.ttl` into the bundle as a
POST_BUILD step. Before that existed, `generate_plugin_ttl()` and
`generate_manifest_ttl()` had **no caller outside the test suite**: the build
compiled the shared object into `<name>.lv2/` and stopped, while the comment
above it claimed the directory held "the .so and .ttl files". A host discovers
plugins by reading `manifest.ttl`, so every bundle this tree produced was not a
plugin that loaded badly — it was not a plugin at all, silently.

**The description comes from the module, not from CMake.** The port layout is
the plugin descriptor's and the control ports are its parameters', so a build
script that emitted Turtle itself would be a second source of truth for a wire
format the host and `connect_port()` must agree on exactly. Instead the
`PULP_LV2_PLUGIN` macro exports `pulp_lv2_write_bundle_ttl`, and
`tools/lv2-ttlgen` dlopens the module that was just built and asks it. One
driver serves every target because it links nothing from the plugin.

Consequences worth knowing:

- **It is host-side.** Cross-compiling, iOS and Android skip the driver, and
  `_pulp_add_lv2` then emits a loud CMake warning rather than a bundle that
  looks finished. A bundle without a manifest is indistinguishable from a
  working one until a host fails to list it.
- **`generate_manifest_ttl()` points `rdfs:seeAlso` at `<binary-stem>.ttl`.**
  The description has to land on exactly that name, or a host reads the
  manifest, follows the pointer and finds nothing — which looks to a user
  exactly like a plugin that does not exist.
- **A claim in a comment is not a claim in the output.** Each LV2 header
  described the atom output port as "sized by `lv2:minimumSize` in the TTL"
  while the generator emitted no such property for months, so hosts used their
  own default. When the artifact is generated text, the only honest check is
  one that reads the generated text.
- **No example requests LV2.** 68 `FORMATS` declarations under `examples/` and
  none names it, so no ordinary build here exercises `_pulp_add_lv2`. That is
  why the end-to-end coverage is a dedicated fixture
  (`test/native_components/lv2_ttl_fixture_plugin.cpp`) that runs the *same*
  POST_BUILD invocation and is read back through `pulp::host`'s own discovery —
  a test that called the generator directly would prove the generator works and
  nothing about whether the build ever runs it.
- **`pulp create` still offers LV2.** On a non-macOS, non-Windows host the
  default format list in `experimental/pulp-rs/src/cmd/create_formats.rs`
  includes it and the scaffold drops in an `lv2_entry.cpp`.

## Port indices are a wire format — one definition, and it is load-bearing

`Lv2PortLayout` (`core/format/include/pulp/format/lv2_adapter.hpp`) is the
single definition. `generate_plugin_ttl()` builds one to emit `lv2:index N`,
and `connect_port()` classifies a host-supplied index through the same type's
`kind_of()` / `slot_of()`. Do not re-derive the order anywhere else.

The order is: every audio input channel, every audio output channel, one
control port per parameter, the atom input port *if* `accepts_midi`, the atom
output port *if* `produces_midi`, then the latency output control port, always
last. It was genuinely written twice once, with nothing comparing them; the
failure that shape produces is the host handing a `float*` to a slot the
adapter reads as an `LV2_Atom_Sequence` — a type confusion that compiles, links
and loads. `test_lv2_bundle_discovery_e2e.cpp` is what now compares the emitted
indices against the reader, on a real built bundle.

`kind_of()` checks both ends of every range and answers `None` for a negative
or past-the-end index, so `connect_port()` cannot land outside an array;
`instantiate()` additionally refuses a descriptor wider than `kMaxChannels`
rather than dropping ports one at a time, because a dropped connection renders
as silence and reads as a DSP fault instead of a declaration fault.

Worse, **the index is the host's saved wire format.** Hosts store a session's
port connections by index, not by symbol. Inserting a port renumbers every port
after it, so a session saved against the old layout silently reconnects to the
wrong things. Adding a port to an already-shipped plugin is a compatibility
event, not a feature — which is why "just give every plugin an atom input port"
is not an available answer to the transport problem below. Treat that as a
decision rather than an oversight, and state it in `format_limitations.lv2`
rather than in a comment somewhere, so the next reader finds it.

One related sharp edge, and the reason the channel ceiling must be enforced at
**admission** rather than at connection. `audio_in_ports` / `audio_out_ports`
are `float*[kMaxChannels]` with `kMaxChannels` = 8. A descriptor whose buses sum
past eight per direction writes past the array — and the two arrays are adjacent
members, so index 8 and 9 land on `audio_out_ports[0..1]` and silently mis-wire
outputs rather than crashing. Casting the host's port number to `int` is the
second half of the same hazard: a port past `INT_MAX` narrows to a **negative**
index that an upper-bound check still accepts.

**Do not assume `run()` is the safe half.** It clamps its pointer-gathering
loops, then hands `BufferView` the *unclamped* channel count over that same
eight-pointer array — and `BufferView::channel()` does not bound-check, so the
Processor reads stack garbage as a `float*`.

Enforce the ceiling in `instantiate()`, before any allocation, and return
`nullptr` the way the missing-`urid:map` refusal already does. Clamping inside
`connect_port()` is the wrong shape: a silently dropped connection is a
declaration fault wearing a DSP fault's symptoms, and it leaves the `run()` half
unfixed.

## Transport arrives as an atom, on the MIDI port

LV2 has no transport API. A host publishes transport by writing a
`time:Position` **object into an atom input port's sequence** — the same
sequence that carries MIDI events. That has three consequences that surprise
people arriving from CLAP or VST3:

1. **No MIDI port, no transport.** A plugin with `accepts_midi = false` has no
   atom input port, so there is nowhere for a `time:Position` to arrive. A
   tempo-synced effect that takes no MIDI is blind under LV2 by construction.
   See the port-renumbering paragraph above for why the obvious fix is not one.
2. **You must walk past events you do not recognise.** The sequence is mixed.
   An event is transport only if its `body.type` is `atom:Object` — *or*
   `atom:Blank`, which pre-1.8 hosts still send for the same thing. Match both
   or you will decode nothing on an older host and see no error anywhere.
3. **A block can carry more than one.** A seek mid-block produces two
   Positions; the last one is the one this block runs under.

### `time:Position` is latched, not per-cycle

Nothing in the spec requires a host to send a Position every cycle, and real
hosts send one only when something changes. So a decoder that reads the port
and nothing else sees the *same* sample position on block after block — which
downstream reads as the transport seeking back to the same spot every buffer,
continuously resetting anything phase- or tempo-locked. The symptom is an LFO
or arpeggiator that will not advance while the host plays normally.

Retain the last Position, advance it by the block length while `speed` is
non-zero, and let a freshly arrived Position overwrite the extrapolation
outright — that way a host which *does* send one per cycle never accumulates
drift, and one which does not keeps moving.

### The unit traps in `time:`

- **`time:beat` counts beats of `time:beatUnit`, not quarter notes.** Pulp's
  `ProcessContext` counts quarter notes. Scale by `4 / beatUnit` or every
  reading from a 6/8 host is wrong by a factor of two.
- **`time:framesPerSecond` is the audio sample rate.** It is not an SMPTE
  frame rate, despite the name; there is nothing to map a `FrameRate` from.
- **`time:speed` is a rate, not a boolean.** Zero is stopped, one is normal
  play, and anything else is a scrub or a varispeed.
- **Every property is optional.** A bare sample transport sends `time:frame`
  and `time:speed` and nothing musical. Carry presence per field so a consumer
  can tell "the host did not say" from "the host said 120". LV2's time
  extension models no record-arm, no cycle range and no host clock at all, so
  those stay unavailable rather than defaulted.

Do the decode into LV2's own units first and project onto the shared
`boundary::HostTransport` second, the way the other adapters do — the mapper in
`adapter_boundary.hpp` owns bar derivation and the change flags, and a decoder
that computes those itself will disagree with every other format.

## Block size: LV2 tells you nothing, and then tells you a hint

`instantiate()` receives a sample rate and no block size. The only way to learn
one is `bufsz:maxBlockLength`, delivered through the `options` feature.

**Both of those are optional features, and that is the whole trap.**
`bufsz:boundedBlockLength` is something a host *may* support; `options` is
something a host *may* provide. Reading a `maxBlockLength` therefore tells you
what a cooperative host intends, and promises nothing about what `run()` will
actually be handed. A host that supports neither may legitimately call `run()`
with any `n_samples`, and JACK-family period sizes are user-configurable at
runtime. A plugin that sizes scratch from that number and trusts it overruns
the first time somebody changes the period.

Follow the rule CLAP and VST3 follow: whatever ceiling `prepare()` was given,
`run()` must clamp `n_samples` to it and zero-fill the tail
`[max, requested)` on every output channel, so the host reads clean silence
instead of stale buffer contents. `clamp_block_to_prepared_max()` in
`max_block_contract.hpp` is the shared decision. **Its header comment enumerates
the wired adapters and that list is itself stale — it omits LV2 even though
`lv2_entry.hpp` calls `clamp_block_to_prepared_max()`.** Verify against the call
sites, not the comment; the comment is the thing that drifted.

Use the same discipline for the atom output buffer. The host allocates it; the
plugin's only influence is `lv2:minimumSize` in the TTL, and its actual
capacity arrives in `atom.size` on entry to `run()`. Read the capacity you were
given, and drop events that do not fit rather than writing past it.

## State: two channels, and they are not equivalent

Control ports are the **host's** state. It saves and restores them itself, and
it writes them back into the ports before the next `run()`. Nothing the plugin
does at save time can change that.

Everything else — sampler buffers, file references, any blob a Processor
returns from `serialize_plugin_state()` — has exactly one route home, and that
is `state:interface`. Without it a session reload restores the knobs and loses
the content, which reads to a user as "the plugin forgot my sample" rather than
as a missing feature.

Wiring it has two halves and **both are load-bearing**:

- `LV2_Descriptor::extension_data` must return the interface for
  `LV2_STATE__interface`.
- The TTL must declare `lv2:extensionData state:interface`.

A host checks the Turtle to decide whether to *ask*. Return the interface
without declaring it and it is never called; declare it without returning it
and the manifest advertises a capability the binary does not have. Given that nothing currently writes the Turtle
at all (see above), the declaration half is the one that will bite.

Further notes that cost time to rediscover:

- **The parameter half of the shared state envelope is redundant here, and
  loses.** Pulp's `plugin_state_io` envelope carries both the `StateStore`
  payload and the plugin-owned payload. Under LV2 the control ports overwrite
  the parameter half on the next `run()` regardless of what was restored, so a
  restore is authoritative only for the plugin-owned part. Do not debug a
  "parameter did not restore" report on this path without checking the port
  value first.
- **`restore()` runs in LV2's Instantiation threading class**, so no `run()`
  is in flight and a deserialize gets the non-concurrent context it documents.
  This is a guarantee worth relying on and worth not breaking.
- **Restoring does not re-derive.** A restored state can name a different
  sample set or impulse response than the live one. Reconcile derived resources
  after a restore or the session renders the previous content until something
  else happens to invalidate it.
- **There is no path mapping.** `state:mapPath` and `state:makePath` are
  unused, so an absolute path inside a saved payload does not survive the
  bundle or the project moving to another machine.

## What `run()` may not do

`run()` is the audio thread and nothing tells you otherwise. The adapter holds
the line with `ScopedNoAlloc` around the whole render — MIDI parse, `process()`,
MIDI serialise — plus `ScopedFlushDenormals` for the same reason every other
adapter has it. The MIDI scratch buffers are reserved once in `instantiate()`
with `set_realtime_capacity_limit(true)`, so an overflowing block drops events
instead of growing a vector under the guard.

That means anything you add inside `run()` must be allocation-, lock- and
syscall-free, including indirectly. The two that catch people:

- **`LV2_URID_Map::map()` is not real-time safe.** It is a host callback that
  may take a lock and intern a string. Map every URI you will ever need once,
  in `instantiate()`, and compare integers on the hot path. A URID of zero then
  means "the host gave us no map", and every comparison against it fails
  closed, which is the behaviour you want.
- **`instantiate()` refuses without `urid:map`** and returns `nullptr` so the
  host reports a clear error, rather than instantiating something that silently
  cannot decode an atom. Keep that shape for any feature you make mandatory.
  `lv2:requiredFeature` does tell a conformant host not to load you without it,
  but it is a line of Turtle — it protects you only as far as the host is
  conformant and the manifest actually reached it, which is a second reason the
  runtime check has to exist.

Hosts that care about real-time behaviour (Ardour, Reaper) read
`lv2:optionalFeature lv2:hardRTCapable` out of the Turtle and treat its absence
as "not safe for the real-time thread". There is no other channel for that
claim — which makes it one more thing riding on a manifest that has to be
written first.

## Testing LV2

**Parse the Turtle; do not grep it.** The existing TTL tests are
`ContainsSubstring` assertions, which is why a property promised in two header
comments for months and never actually emitted went unnoticed: a substring test
can only fail on text it was told to look for. A substring also cannot tell you
that a triple landed on the right subject, that a port index is attached to the
port you meant, or that a URI resolves. Load the generated text with an RDF
parser (`rdflib` reads Turtle directly), then assert over triples: this subject
is an `lv2:Plugin`, it has N ports, the port with `lv2:index 4` is a
`lv2:ControlPort`, every prefix used is declared. That catches the whole class
of "the generator emits plausible-looking text that means nothing".

If the host tools are available, `lv2lint` and `sord_validate` will grade a
real bundle far more harshly than any test here, and loading it in Ardour or
Carla is the only check that covers discovery, port wiring and session reload
end to end.

**Two harness traps bit this work specifically**; both are general, and both
live with the rest of the CTest selection traps in
[docs/guides/test-lanes.md](../../../docs/guides/test-lanes.md):

- A **comma in a Catch2 case name** makes that name unusable as a filter — the
  runner reads it as several specs, prints `No tests ran`, and exits 2. The
  expensive part is what happens next: `confirm_failure.sh` reports that exit
  as `INCONCLUSIVE — the test already fails before any edit`, so a perfectly
  good test reads as one that does not cover its code, and the obvious response
  is to go rewrite something that was never broken. Name new cases without
  commas; escaping them (`\,`) recovers an existing one.
- **`ctest -N -R` treats a literal `(` in a name as a regex group**, so a case
  whose name contains `run()` is selected zero times until the parens are
  escaped — and a selection that matches nothing still exits 0.

Both are the same failure: a measurement aimed slightly wrong returns a clean,
confident, empty answer. Pair every zero with a control on the same instrument
that must return non-zero.

## Honest limits

`format_limitations.lv2` in `docs/status/support-matrix.yaml` is the canonical
list; read it rather than trusting a summary. The shape of what is missing, so
you know what you are looking at:

- **No editor.** There is no `lv2:ui` surface, so a plugin's view is
  unreachable under LV2 and a host renders the generic control-port strip.
- **No presets.** Neither the `patch:` nor the `pset:` vocabulary is
  implemented, so `PresetManager` is unreachable from an LV2 host.
- **No worker.** `LV2_Worker` is not wired, so there is no sanctioned way to do
  non-real-time work on behalf of `run()`.
- **No sysex.** The input walk promotes only 1–3 byte short messages out of the
  sequence; a variable-length atom is skipped.
- **No path mapping**, as above.
- **No MPE or UMP sidecar** — and the two have different reach, so do not
  conflate them. The MPE sidecar (`set_mpe_input`) is wired on VST3 and AUv3.
  The UMP sidecar (`set_ump_input`) is CLAP-only. Neither is wired here, so
  expressive input degrades to plain MIDI 1.0 on this path.
- **No latency-compensated bypass.** `LatencyCompensatedBypass` is wired into
  CLAP, VST3, AU and AAX and not into LV2, so `run()` always calls `process()`
  and a bypassed latent plugin is not delay-compensated the way it is
  elsewhere.

Each of those is a real gap rather than a hidden feature. If you find this page
or the matrix disagreeing with the code, the code wins — fix the matrix in the
same change and say so.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…