lv2-wasm32 is a work-in-progress runtime bridge for running LV2 plugin code
compiled as WebAssembly (wasm32-wasip1) while preserving the familiar LV2
API.
The current repository is a proof of concept, not a finished DAW integration layer. Think of this like a sketch before the final painting.
Today it demonstrates that:
- A native C host can load an LV2 plugin compiled to WASM by using the wasmtime C API.
- Audio and control data can move between the native host and the guest module through a simple, explicit linear-memory model.
This project uses just (a command runner) to execute a simple demo. The build
script automatically fetches required dependencies from GitHub and places them
in the local project directory.
1. Install required packages (Ubuntu/Debian example):
sudo apt install build-essential lv2-dev just pkg-config2. Fetch dependencies:
just fetch-deps3. Build and run the demo:
just runThe long-term goal is to extend LV2 plugins to be more portable and sandboxable by moving plugin implementation code into WASM, while still fitting seamlessly into LV2 host expectations.
To achieve this, the architecture adheres to the following design pillars:
1. Seamless Host Integration
- The LV2 API contract must remain strictly intact.
- From the host's perspective, ports should behave exactly like normal LV2 ports.
- The native side owns all VM setup, memory movement, and any proxying required for host services.
2. Future-Proofing and Architecture Independence
- Plugin developers have an option to support WASM to extend the life of a plugin, ensuring its DSP logic does not rely on a specific OS or CPU architecture.
- A single LV2 module can support both a traditional native shared library (for bare-metal execution on known targets) and a WASM module (as a portable, sandboxed, or long-term fallback).
Example manifest.ttl illustrating a hybrid binary declaration:
<http://example.org/plugin>
a lv2:Plugin ;
lv2:binary <plugin.so> , <plugin.dylib> , <plugin.dll> , <plugin.wasm> ;
rdfs:seeAlso <plugin.ttl> .3. A Strict Runtime Boundary
This implies a cleanly split architecture where responsibilities are explicitly divided:
- Native side: LV2-facing wrapper, VM runtime, memory bridge, extension proxies, and UI integration.
- WASM side: Actual plugin DSP logic, internal state, and guest-resident heap allocations.
The repository currently proves the core bridge, but it does not yet implement a complete LV2 packaging and interoperability story.
What is implemented and validated today:
- Wasmtime Integration: Loading and instantiation of a WASM LV2 module via the Wasmtime C API.
- Guest Discovery: Resolution of guest exports including
lv2_descriptor, linear memory, and guest allocator helpers. - Lifecycle Forwarding: Full native-to-guest routing for
instantiate,connect_port,activate,run,deactivate, andcleanup. - Memory Bridge: A simple, explicit host-to-guest memory model for audio, CV, and scalar control data.
- End-to-End Validation: A standalone demo host that successfully loads
build/plugin.lv2/plugin.wasm, runs a sample passthrough plugin, and round-trips input samples to output samples.
What is not implemented yet:
extension_data()bridging: Required to support advanced LV2 features, state saving, and worker threads across the WASM boundary.- TTL-driven discovery: Port classification is currently manual; the host does not yet parse TTL metadata to configure the bridge.
- Real DAW Integration: We lack the native LV2 wrapper/proxy plugin
(
.so/.dylib) that a real DAW needs to discover and load the WASM bundle. - Custom UI Support: The bridge currently relies entirely on the DAW's generic parameter view. It will also be low in priority, due to its complexity.
flowchart LR
subgraph Native["Native Environment"]
NativeHost["Native Caller / LV2 Host Wrapper"]
TtlMetadata["TTL Metadata"] -.->|Planned| HostAPI
ExtProxies["Extension Proxies"] -.->|Planned| HostAPI
UiBridge["UI Bridge"] -.->|Planned| HostAPI
NativeHost -->|C API| HostAPI["lv2_wasm32_host API"]
end
subgraph VM["Wasmtime Boundary"]
HostAPI -->|Manages| WasmEngine["Engine + Store + Instance"]
end
subgraph Guest["WASM Sandbox (Guest)"]
WasmEngine -->|Reads/Writes| GuestMemory[("Guest Linear Memory")]
WasmEngine -->|Invokes| GuestExports["Guest Exported Functions\n(run, connect_port, etc.)"]
WasmEngine -->|Invokes| GuestAllocators["Guest Allocator Helpers"]
GuestAllocators -->|Allocates| GuestMemory
GuestExports -->|Executes| WasmPlugin{"WASM LV2 Plugin\n(DSP & State)"}
GuestMemory <-->|Audio/CV/Control Data| WasmPlugin
end
classDef native fill:#e1f5fe,stroke:#0288d1,stroke-width:2px;
classDef vm fill:#fff3e0,stroke:#f57c00,stroke-width:2px;
classDef guest fill:#e8f5e9,stroke:#388e3c,stroke-width:2px;
class Native native;
class VM vm;
class Guest guest;
-
include/lv2_wasm32/lv2_wasm32.h- WASM-specific export helpers, will need to be merged with
lv2.hin the future. - Rebinds
LV2_SYMBOL_EXPORTunder__wasm32__so the guest exportslv2_descriptorwith the expected symbol name.
- WASM-specific export helpers, will need to be merged with
-
include/lv2_wasm32/lv2_wasm32_host.h- Public native API for creating and driving a WASM-backed LV2 instance.
- Defines the port classification flags and the host-side port map contract.
-
host/lv2_wasm32_host.c- Main runtime bridge.
-
host/main.c- Standalone demo harness.
- Shows the intended lifecycle sequence and validates the sample passthrough plugin.
-
lib/wasm/lv2_wasm32_lib.c- Guest-side helper library compiled into the WASM module.
- Exports allocator functions the host can call to reserve space inside guest memory.
- Without this,
malloc()andfree()are not automatically exported into the WASM module.
-
plugin.lv2/plugin.c- Minimal sample LV2 plugin compiled into WASM.
- Implements a two-port passthrough processor.
-
plugin.lv2/manifest.ttlandplugin.lv2/plugin.ttl- Metadata examples copied into the build bundle.
- Not consumed by the current standalone host path.
The guest plugin is compiled with wasi-sdk for wasm32-wasip1 in reactor
mode:
- Reactor mode is important because the module behaves like a library with exported functions instead of a standalone command.
- The build exports the indirect function table so the host can resolve
function references stored in the guest's
LV2_Descriptor. - The WASM build includes both the sample plugin and the small helper library that exposes guest allocation APIs.
The native host side is compiled against:
- LV2 headers for the API types.
- wasmtime C headers and libraries for VM management.
- Standard C runtime plus sanitizers in the main build target.
Each live WASM-backed plugin instance is represented by LV2_WASM32_Plugin. It owns:
- A wasmtime engine and store.
- The instantiated WASM module.
- The exported linear memory handle.
- Guest function handles for the LV2 lifecycle.
- Guest allocator function handles.
- The guest-side plugin instance handle returned by
instantiate. - A host-managed port map that ties native pointers to guest offsets.
- The configured maximum block size used to size guest buffers.
This is the central state object for the bridge.
The current bridge assumes a guest ABI with three important properties:
- The guest exports
lv2_descriptor. - The guest exports linear memory as
memory. - The guest exports helper functions:
lv2_wasm32_aligned_alloclv2_wasm32_alloclv2_wasm32_free
The host reads a guest-side mirror of LV2_Descriptor from guest memory. That
mirror stores:
- A guest offset to the URI string.
- Function table indices for LV2 lifecycle callbacks.
The host then resolves those indices through __indirect_function_table and
stores the resulting callable wasmtime function references.
This is the core architectural trick that makes an ordinary LV2-style descriptor usable inside WASM.
lv2_wasm32_plugin_create() performs VM bring-up and bridge initialization:
- Allocate the native
LV2_WASM32_Plugin. - Copy caller-supplied port class information into the host port map.
- Create the wasmtime engine and store.
- Read and compile the WASM module from disk.
- Configure WASI and instantiate the module.
- Call
_initializewhen present. - Discover guest allocator exports.
- Discover guest memory.
- Call guest
lv2_descriptor(0). - Read the descriptor struct out of guest linear memory.
- Resolve lifecycle function references from the indirect function table.
- Pre-allocate guest memory for every declared port.
At the end of create, the VM exists and all bridge pointers are resolved, but
the guest plugin instance itself has not yet been created. That happens during
instantiate.
lv2_wasm32_plugin_instantiate() forwards the LV2 instantiate call into the
guest and stores the returned guest instance handle.
Current behavior:
sample_rateis passed through.bundle_pathis not yet serialized into guest memory.featuresare not yet serialized into guest memory [TODO]
This means the prototype only supports plugins that do not depend on bundle paths or host-provided features.
lv2_wasm32_plugin_connect_port() does two things:
- It tells the guest plugin which guest-memory offset corresponds to that port.
- It stores the native host pointer locally in the host-side port map.
The guest never receives a native pointer. It only sees an offset into its own linear memory. The native pointer remains host-owned.
These functions are simple lifecycle forwarding calls into the guest. The host does not inject special policy here yet.
lv2_wasm32_plugin_run() is the main data bridge:
- For every connected input port, copy native data into the corresponding guest buffer.
- Call the guest
run()function with the guest instance handle and frame count. - Refresh the linear-memory base pointer in case the guest reallocated memory during execution.
- For every connected output port, copy guest data back into native host buffers.
This design is explicit and simple. It is also intentionally not zero-copy.
The current memory model is the second milestone already achieved by the project.
- The guest owns all memory visible to guest code.
- The host owns all native audio/control pointers.
- Port connections are modeled as offset mappings, not shared raw pointers.
- Audio/CV data is block-copied at
run()time. - Scalar control values are copied as a single
float.
During create, the host allocates guest memory for ports in advance:
- Audio and CV ports get a dedicated aligned block sized as:
max_block_size * sizeof(float)
- Control ports are packed into a single scalar area:
- one
floatper port
- one
Current assumptions:
- Audio, CV, and control values are a block of
float. - A single
max_block_sizechosen at instance creation time is sufficient for all futurerun()calls, so memory is never reallocated. - The guest allocator exports remain valid for the entire lifetime of the module instance.
This is the largest missing architectural piece.
There are two related but distinct problems:
LV2 features are native structs, often containing:
- host-owned pointers
- callbacks
- nested data
- extension-specific ABI rules
Those cannot simply be reinterpreted inside WASM. They need either:
- Serialization into guest memory, or
- Native proxy objects that expose a WASM-safe ABI.
The guest may advertise extension entry points or interface structs. In native LV2, those are returned as pointers to structs with function pointers. Inside WASM, that pointer is only meaningful relative to guest memory and guest function tables.
The native side therefore needs a translation layer that can:
- Ask the guest for the extension.
- Read the guest-side representation.
- Construct a native proxy object matching the expected LV2 extension ABI.
- Route native calls back into guest code safely.
This is why lv2_wasm32_plugin_extension_data() is still unimplemented today.
- This is currently a standalone demo host, not a complete DAW-facing LV2 wrapper.
- Real-time behavior has not been formally validated.
- The runtime depends on wasmtime, so deployment and packaging must account for the native wasmtime C library.
- The guest ABI currently assumes 32-bit offsets and a stable descriptor layout.
- The bridge is copy-based, which is simple and safe, but not the final word on performance.
- The metadata path is not authoritative yet.
- Feature and extension support will require explicit ABI design, not just more plumbing.
Status: complete
Delivered:
- Compile LV2 plugin code into WASM.
- Load the WASM module in a native C host using wasmtime.
- Resolve exported memory and lifecycle entry points.
- Forward core LV2 calls into the guest.
Status: complete
Delivered:
- Host-side port map from native buffers to guest offsets.
- Guest-side allocation helpers for buffer reservation.
- Input copy into guest memory before
run(). - Output copy back to host memory after
run(). - Validation through the passthrough sample plugin.
Status: proposed
Goals:
- Extend
lilv's plugin loading logic to recognize.wasmbinaries declared in LV2 TTL metadata. - Embed the
lv2_wasm32_hostbridge withinlilv's instantiation pipeline so it can manage the Wasmtime engine and lifecycle. - Abstract the Wasm-to-native boundary completely, ensuring that host
applications using
lilvreceive a standardLilvInstanceand do not need to manage the VM themselves. - Replace the bridge's current manual port classification with
lilv's authoritative, TTL-driven port mapping.
Definition of done:
- A standard
lilv-based tool (such asjalvorlv2info) can successfully discover, inspect, and execute a WASM-backed LV2 plugin without modification. - A DAW using
lilvcan load a hybrid bundle (containing both a.soand.wasmfile) and route audio through the WASM guest seamlessly.
Status: proposed
Goals:
- Expose a real LV2 UI strategy beyond the generic DAW control surface.
- Define message transport between native UI code and guest state.
- Choose a host-compatible first UI path, likely a native proxy UI.
Definition of done:
- At least one WASM-backed plugin can expose a custom UI in an LV2 host environment.
If development continues from the current codebase, the most sensible order of operations is:
- Gather feedback and requirements: Present this proof-of-concept to the broader LV2 and DAW development communities to validate the proposed host-guest boundary and memory model assumptions.
- Start integrating into the
lilvproject: Shift from a custom standalone host to alilv-embedded bridge. This immediately solves the metadata discovery gap and allows testing with standard ecosystem tools likejalv. - Design the feature and extension ABI: Before adding more complex plugin
DSP, define how essential LV2 features (specifically
urid#mapandatomevents) will be serialized or proxied safely across the WASM boundary. - Tackle the UI architecture: Once DSP, metadata, and control events are
bridging successfully through
lilv, establish a host-compatible native proxy UI model.
The project already demonstrates the essential core idea: an LV2 plugin can be compiled to WASM, loaded with wasmtime, and executed through a native C bridge that preserves LV2 lifecycle calls and moves port data through guest linear memory.
That is the right foundation. The remaining work is no longer about proving basic feasibility. It is about turning the bridge into a real LV2 interoperability layer by solving metadata, features, extension data, UI, and host compatibility.
Large Language Models (LLMs) were utilized in the creation and modification of this document. All architectural concepts, technical claims, and final phrasing have been strictly reviewed and validated by human contributors.