本文へ移動
cccskills
無料GitHub で公開

lv2

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.

インストール方法を見る

含まれるファイル(1)

  • SKILL.md20.5 KB

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

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

RolePath
Turtle generation (generate_plugin_ttl, generate_manifest_ttl)core/format/src/lv2_adapter.cpp
Instance struct, URID cache, port pointerscore/format/include/pulp/format/lv2_adapter.hpp
instantiate / connect_port / run / cleanup + PULP_LV2_PLUGINcore/format/include/pulp/format/lv2_entry.hpp
Bundle build rule (_pulp_add_lv2)tools/cmake/PulpPluginFormats.cmake
Scaffolded per-plugin entrytools/templates/{gain,effect,instrument}/lv2_entry.cpp.template
Shared over-long-block clampcore/format/include/pulp/format/max_block_contract.hpp
Shared transport → ProcessContext mappercore/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
Teststest/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:

  • 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.

レビュー

まだレビューはありません。使ってみた感想をお寄せください。

同じリポジトリのスキル

概要と使いどころ

aax

無料

Optional AAX support for Pulp, including developer-supplied Avid SDK setup, CMake enablement, DigiShell/AAX Validator workflows, and local AAX builds on macOS or Windows.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

Configure, implement, and test Pulp's optional desktop Ableton Link tempo-sync adapter while preserving the developer-supplied SDK, licensing, realtime, latency-compensation, and no-install boundaries.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

Maintain Pulp's installed design-time agent capability manifest and public-surface ledger. Use when adding, removing, renaming, or materially changing public audio, MIDI, signal, timebase, or sequence APIs; registering a new algorithm for generators; changing capability support or deprecation state; or repairing agent-capabilities freshness, schema, fingerprint, tombstone, or installed-SDK tests.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

android

無料

Android platform development for Pulp — NDK cross-compilation, Oboe audio, Dawn/Skia GPU rendering, JNI bridge, touch interaction, emulator workflows, and end-to-end smoke validation. Covers build, deploy, debug, and the gotchas discovered during bringup.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

ara

無料

Optional ARA support for Pulp, including developer-supplied ARA SDK setup, CMake enablement, adapter companion APIs, validation, and ARA-aware plugin implementation guidance.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

The measurement surface for ALL Pulp DSP and audio-pipeline work — read it BEFORE writing or gating DSP, not only when something already sounds wrong. Covers the C++ harness (signal generators, metrics, assertions, RenderScenario, contracts), the offline Audio Doctor (magnitude/frequency response, THD/THD+N, phase/group delay), and their Python sibling the Audio Quality Lab (tools/audio/quality-lab — null residual + alignment, LTAS log-spectral distance, spectral flux/centroid, HNR, Theil-Sen drift slope, Kaiser-sinc resampling, license-guarded corpus, regression-net ratchet). TRIGGER on AUTHORING work — "build/design an oscillator/filter/synth/effect", "add a DSP module", "what should the acceptance gate be", "how do I measure aliasing / anti-aliasing / alias floor", "null against a reference", "is this DSP correct", "choose a tolerance", "golden/regression corpus for audio", "measure drift or jitter", "A/B two renders" — AND on DEBUGGING work — "is there sound / no audio / I hear nothing", "does this filter/compressor/synth/delay produce the right signal", "prove the DSP / prove the contract", "measure the frequency response", "what's the THD / is it distorting", "what's the group delay / phase response / measured latency", "magnitude response curve", "render a test tone and assert", "audio regression", "64-frame works but 128 is silent", "sample-rate change pitch-shifted it", "describe what's in this buffer", "audio doctor", "compare before/after a DSP refactor". Reach for this BEFORE hand-rolling any FFT, null test, alias measurement, pitch tracker, or golden-render script — most of it already exists in one of the two lanes. Test/tool layer over HeadlessHost — deterministic, no audio device, no speakers. Off the realtime thread entirely.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

Generous-Corp のスキルをすべて見る

このスキルの問題を報告する