Skip to content

Generic RT/NRT shared resource system (with MultiChannelBuffer as first class) #147

@kaoskorobase

Description

@kaoskorobase

Motivation

The engine currently has no general-purpose mechanism for sharing data between the RT audio thread and NRT context that can be allocated/freed at runtime via OSC. AudioBus is preallocated at engine init; synth instance memory is RT-owned. There's no story for, e.g., a sample buffer that:

  • is allocated/freed via OSC,
  • is written by a record synth in RT,
  • is read by play synths in RT,
  • can be analyzed (onset detection) or processed (heavy DSP) in NRT.

This issue specifies a generic resource protocol — plugins can register new resource types — with AudioBuffer as the first concrete class. Resources are identified by integer id; consumers obtain a type-checked pointer via URI match (similar to a dynamic cast).

Scope

In scope:

  • Generic resource definition registration via plugin API.
  • OSC verbs for resource lifecycle.
  • Synth integration: new kMethcla_ResourcePort port kind.
  • NRT-side use via a dispatch primitive that brackets the use with refcount.
  • One concrete public type: AudioBuffer (multi-channel sample buffer; a new public plugin-API type, not a rename of the engine-internal MultiChannelBuffer).
  • Extension of /node/set to accept i:value for resource ports (the existing f:value form for control ports is unchanged).

Out of scope (future):

  • Per-plugin unload while resources of its class are live.
  • Atomic refcount, dying flag, sweeper-style reclamation — unnecessary given methcla's threading topology (see ADR draft "RT-only mutation of resource state").
  • Reader/writer locks; engine-enforced acquire granularity.
  • Mid-life resource swapping under a running synth without going through connect().
  • Adding a return value to Methcla_SynthDef.construct so pure-C plugins can signal construct failure — useful but independent.

Prerequisite (separate issue): dead-code cleanup in src/Methcla/Audio/Resource.hpp plus ResourceIdAllocatorIdAllocator rename — tracked in #148. The names freed by that cleanup are reused in this design.

Design decisions

# Decision
1 Payload-mutability is per-class; the resource pointer published by the engine is itself immutable. ResourceDef declares Mutable or Immutable. Both values are informational (see ADR draft "Mutability is informational").
2 Default acquire granularity is per-synth-lifetime — acquire at construct, release at destroy, dereference the cached Methcla_Resource* per pass. The protocol equally permits per-pass acquire/release for synths that need tighter free-time bounds or hold the resource only intermittently. Refcount is single-writer RT in either pattern.
3 Resource refs are a new port kind kMethcla_ResourcePort; runtime re-map via /node/set i:node-id i:port-index i:resource-id. Synth options may also carry resource ids for shape-hint cases consumed by port_descriptor.
4 Engine owns a fixed-size resource pool (Options::maxNumResources). Per resource id it tracks {state, uri, resource: Methcla_Resource*, destroy_fn, refcount, free_pending}. Plugin owns the resource's heap allocation.
5 Interface contract: a resource URI names a C struct layout declared in a plugin-supplied header. The engine treats Methcla_Resource* as opaque (mirroring Methcla_Synth* opacity for synth instance bodies). Consumers cast the pointer after URI match. The struct may be POD, function-pointer-heavy, or both — the engine doesn't care.
6 All per-resource state mutation, refcount mutation, and lifecycle dispatch run on RT. NRT is a pure callback path for construct / destroy / perform_with_resources. No atomics needed (see ADR draft "RT-only mutation of resource state").
7 NRT-side use is bracketed by methcla_world_perform_with_resources (RT-side refcount++ on dispatch, auto-release after NRT callback returns). The Methcla_Resource* is valid only for the duration of one perform call.
8 Synth-side bookkeeping is the plugin's responsibility (explicit acquire/release primitives). C++ wrapper provides RAII ResourceRef<T> for the per-synth-lifetime pattern plus a try_acquire flavor returning std::optional.
9 Id encoding: plain int32 resource id; client picks ids (NodeId / AudioBusId precedent).
10 Lifecycle notifications (/resource/ready, /resource/error, /resource/destroyed) originate from RT post-state-transition, dispatched via NRT — mirrors /node/done. Required to avoid races between a NRT direct-notify and a follow-up /synth/new.
11 kMethcla_ResourcePort supports direction (Input / Output) as informational metadata; the engine does not enforce mutability against direction. Methcla_PortDescriptor stays unchanged — URI checking remains in the synth's connect() via methcla_world_resource_acquire(..., expected_uri).

Plugin C API

Opaque resource handle and definition struct (added to include/methcla/plugin.h):

typedef void Methcla_Resource;   // opaque handle to a plugin-allocated resource
                                 // (mirrors `typedef void Methcla_Synth;`)

typedef int32_t Methcla_ResourceId;

typedef enum {
    kMethcla_ResourceMutable,
    kMethcla_ResourceImmutable
} Methcla_ResourceMutability;

typedef struct Methcla_ResourceDef {
    const char* uri;
    Methcla_ResourceMutability mutability;

    // NRT context. Parse args, allocate the resource, return it.
    // Return NULL on failure; engine notifies /resource/error.
    Methcla_Resource* (*construct)(Methcla_Host* host,
                                   const void* tag_buffer, size_t tag_size,
                                   const void* arg_buffer, size_t arg_size);

    // NRT context. Free the resource. Must be infallible.
    void (*destroy)(Methcla_Host* host, Methcla_Resource* resource);
} Methcla_ResourceDef;

Methcla_Host gains one field, parallel to register_synthdef:

struct Methcla_Host {
    /* ... existing fields ... */
    void (*register_resource_def)(Methcla_Host*, const Methcla_ResourceDef*);
};

RT-side primitives

Methcla_World gains two fields:

struct Methcla_World {
    /* ... existing fields ... */

    // Returns the resource pointer after URI check; increments refcount.
    // Returns NULL on URI mismatch, resource not Live, free_pending, or out of range.
    Methcla_Resource* (*resource_acquire)(
        Methcla_World*, Methcla_ResourceId, const char* expected_uri);

    // Decrements refcount; if hits 0 and free_pending, posts destroy-cmd to NRT.
    void (*resource_release)(Methcla_World*, Methcla_ResourceId);
};

Consumers cast the returned pointer to the typed interface struct, e.g. (AudioBuffer*)methcla_world_resource_acquire(world, id, METHCLA_AUDIO_BUFFER_URI).

NRT-side primitive

A new callback signature, distinct from Methcla_HostPerformFunction, with the resolved resource pointers passed as a separate argument:

typedef void (*Methcla_PerformWithResourcesFunction)(
    Methcla_Host* host,
    Methcla_Resource* const* resources, size_t num_resources,
    void* user_data);

// On dispatch RT acquires each resource (refcount++ for each).
// If exclusive=true, dispatch fails unless every listed resource has refcount==1
// at the moment of acquire; on failure no acquires are performed.
// On NRT callback return, RT auto-releases all (refcount--).
void (*perform_with_resources)(
    Methcla_World*,
    const Methcla_ResourceId* ids, size_t num_ids,
    bool exclusive,
    Methcla_PerformWithResourcesFunction perform,
    void* user_data);

(Field on Methcla_World, alongside the resource_acquire/resource_release pair shown above.)

C++ wrappers

Two pieces, both added to include/methcla/plugin.hpp in the Methcla::Plugin namespace.

ResourceRef<T> — consumer-side RAII handle

Implements the per-synth-lifetime pattern. The type parameter is the C++ wrapper class for the resource (see "First resource class" below), which carries the URI and the underlying C struct type:

// Throws methcla::ResourceAcquireError on failure.
methcla::ResourceRef<Methcla::Plugin::AudioBuffer> buf(world, opts.bufferId);

// std::optional, empty on failure.
auto buf = methcla::try_acquire<Methcla::Plugin::AudioBuffer>(world, opts.bufferId);

// In connect(): replace contents, return false on failure (no-op on failure).
bool ok = m_buf.try_replace(world, new_id);

Per-pass synths use the C primitives directly without the smart pointer (acquire at process entry, release before return); the C++ wrapper does not provide a per-pass helper.

The ResourceRef<T> name reuses the symbol freed by the dead-code cleanup in #148 (the previous unused ResourceRef<T> = intrusive_ptr<T> alias).

ResourceDef<Resource, Options> — plugin-author-side definition template

Parallel to Methcla::Plugin::SynthDef<Synth, Options, Ports, Flags>. Bridges the C ABI to a C++ class so resource authors write a normal RAII class:

template <class Resource, class Options = NoOptions>
class ResourceDef
{
    static Methcla_Resource* construct(
        Methcla_Host* host,
        const void* tag_buffer, size_t tag_size,
        const void* arg_buffer, size_t arg_size)
    {
        OSCPP::Server::ArgStream args(
            OSCPP::ReadStream(tag_buffer, tag_size),
            OSCPP::ReadStream(arg_buffer, arg_size));
        typename Options::Type opts(args);
        void* mem = methcla_host_alloc(host, sizeof(Resource));
        try {
            new (mem) Resource(HostContext(host), opts);
        } catch (...) {
            methcla_host_free(host, mem);
            return nullptr;
        }
        return static_cast<Methcla_Resource*>(mem);
    }

    static void destroy(Methcla_Host* host, Methcla_Resource* resource) {
        Resource* r = static_cast<Resource*>(resource);
        r->~Resource();
        methcla_host_free(host, r);
    }

public:
    void operator()(Methcla_Host* host, const char* uri,
                    Methcla_ResourceMutability mutability)
    {
        static const Methcla_ResourceDef kDef = {
            uri, mutability, construct, destroy
        };
        host->register_resource_def(host, &kDef);
    }
};

Resource-class authors then write:

class AudioBufferImpl : public ::AudioBuffer {
public:
    AudioBufferImpl(Methcla::Plugin::HostContext host, const Options& opts);
    ~AudioBufferImpl();
};

Methcla::Plugin::ResourceDef<AudioBufferImpl, AudioBufferOptions>()(
    host, METHCLA_AUDIO_BUFFER_URI, kMethcla_ResourceMutable);

— mirroring the existing Methcla::Plugin::SynthDef<...>()(host, uri) registration idiom.

OSC API

Verb Args Direction Notes
/resource/new s:definition-name i:resource-id [args] client → engine [args] is a single OSC array (matches /synth/new's [synth-options] convention). Parsed by plugin in NRT.
/resource/free i:resource-id client → engine Destroy now if refcount==0; else mark free_pending.
/resource/ready i:resource-id engine → client Resource is Live.
/resource/error i:resource-id s:reason engine → client Construct failed; id returned to Free.
/resource/destroyed i:resource-id engine → client Id returned to Free; reusable.

Synth integration via /node/set — this issue extends the existing verb to accept i:value for the new resource-port case:

Port kind OSC arg Notes
kMethcla_ResourcePort i:value only new in this issue; f: against a resource port = replyError

Resource state machine

                 /resource/new (validated, id reserved)
        Free ──────────────────────────────────────────→ Constructing
         ▲                                                    │
         │                                                    │  NRT construct returns
         │                                          ┌─────────┴─────────┐
         │                                          │                   │
         │                                       NULL                 ptr
         │                                          │                   │
         │ NRT destroy returns                      ▼                   ▼
         │ (state → Free)                       (notify              Live
         │                                       /resource/error)     │
         │                                          │                  │ /resource/free
         │                                          │                  │   refcount==0 → Destroying
         │                                          ▼                  │   refcount>0  → free_pending=true,
         └──────────────────────────────────────  Free                 │                  drain via release path
                                                                       ▼
                                                                  Destroying
                                                                       │
                                                                       │ NRT destroy returns
                                                                       └────────→ Free

free_pending is set on a Live id when /resource/free arrives but refcount > 0; the last release to drain refcount then initiates Destroying. The same flag is set on a Constructing id if free arrives mid-construct; transition to Destroying happens immediately after Constructing → Live, so the id never sits Live-and-doomed beyond one transition.

Threading

Event Thread Touches
/resource/new arrives RT resource Free → Constructing; post NRT construct
NRT construct returns NRT → posts back sends publish-cmd to RT
RT drains publish-cmd RT resource → Live; post ResourceReadyNotification to NRT
NRT dispatches /resource/ready NRT calls packet handler
Synth bind in /synth/new or /node/set RT refcount++
Synth destruction (Node::free) RT refcount-- for each held resource
/resource/free arrives RT if refcount==0 → Destroying + post NRT destroy; else free_pending=true
NRT destroy returns NRT → posts back RT marks resource Free; notifies via NRT
perform_with_resources dispatch RT per-resource refcount++; post NRT perform
NRT perform returns NRT → posts back RT auto-releases all (refcount--)

Critical ordering: EnvironmentImpl::process drains the OSC request queue before m_worker->perform(). Lifecycle notifications must therefore originate from RT after the state transition, dispatched via NRT. Sending notifications directly from NRT would let a client's follow-up /synth/new race the publish-cmd.

Failure modes

Where Surfaces as
/resource/new validate (id range / id in use / definition unknown) replyError, RT-synchronous
Plugin construct returns NULL /resource/error i:id s:reason notification; id returned to Free
/resource/free validate (id invalid / resource not Live) replyError
Plugin destroy Contractually infallible
Resource acquire fails in C++ synth's construct ResourceRef<T> throws → processMessage replyError on the /synth/new
Resource acquire fails in pure-C synth's construct Plugin self-handles: log via methcla_world_log_line, store a "no-resource" state on the synth instance; synth produces benign output (e.g., silence) until destroyed. Cleaning this up would need a construct return value (out of scope here).
Resource acquire fails in synth connect (re-map) Retain prior binding, log warning, silent to client
perform_with_resources dispatch with exclusive=true and refcount>1 Synchronous fail; no acquires performed; client-visible error

Mutation contract published to plugin authors

A ResourceDef declares its mutability as either Mutable (suits buffers) or Immutable (suits wavetables, lookup tables, FFT plans). Both values are informational. The engine does not enforce write rejection; the declaration documents the class's contract for consumers and for plugin authors implementing other resource classes.

For Mutable classes, NRT writes are safe only when nothing else holds the resource. Recommended pattern: process-into-fresh-slot — allocate destination Y, dispatch perform_with_resources({X, Y}, ...) reading X and writing Y, optionally re-bind synths from X to Y via /node/set, free X.

For in-place writes, use exclusive=true. The engine rejects dispatch if any other holder exists, catching lifecycle bugs at dispatch time.

RT-side mutation (record synths) on Mutable classes is the user's responsibility, controlled by node ordering. The engine does not detect this.

For Immutable classes, the contract is by convention: the published interface struct typically has no setter methods, and consumers do not cast away const to write. Engine-side enforcement was considered and rejected (see ADR draft "Mutability is informational").

First resource class: AudioBuffer

AudioBuffer is a new public plugin-API type, distinct from the engine-internal MultiChannelBuffer (src/Methcla/Audio/MultiChannelBuffer.hpp, used by the audio driver for I/O). The two are structurally similar (both wrap multi-channel float arrays) but architecturally separate: MultiChannelBuffer is engine internals; AudioBuffer is the plugin-facing ABI contract. MultiChannelBuffer is unchanged by this issue.

Public C header: include/methcla/plugins/audio_buffer.h

#define METHCLA_AUDIO_BUFFER_URI "http://methc.la/resources/AudioBuffer/v1"

typedef struct AudioBuffer {
    size_t   num_channels;
    size_t   num_frames;
    size_t   sample_rate;
    float**  data;   // num_channels pointers, each to num_frames samples
} AudioBuffer;

Public C++ wrapper: include/methcla/plugins/audio_buffer.hpp

#include <methcla/plugins/audio_buffer.h>
#include <cstring>

namespace Methcla { namespace Plugin {

class AudioBuffer
{
    ::AudioBuffer* m_buf;
public:
    using c_type = ::AudioBuffer;
    static constexpr const char* uri() { return METHCLA_AUDIO_BUFFER_URI; }

    explicit AudioBuffer(::AudioBuffer* buf) : m_buf(buf) {}

    size_t numChannels() const { return m_buf->num_channels; }
    size_t numFrames()   const { return m_buf->num_frames; }
    size_t sampleRate()  const { return m_buf->sample_rate; }
    float* const* data() const { return m_buf->data; }
    float* channel(size_t i) const { return m_buf->data[i]; }

    void zero() {
        for (size_t c = 0; c < m_buf->num_channels; ++c)
            std::memset(m_buf->data[c], 0, m_buf->num_frames * sizeof(float));
    }
};

}}

The static uri() and c_type typedef let ResourceRef<T> discover both at compile time, so callers write ResourceRef<Methcla::Plugin::AudioBuffer>(world, id) and the wrapper handles the URI check and the cast.

Implementing plugin: plugins/audio_buffer.cpp

Lives alongside other built-in plugins (disksampler.cpp, sampler.cpp). Registers a Methcla_ResourceDef for AudioBuffer via the Methcla::Plugin::ResourceDef<> template:

  • Mutability: kMethcla_ResourceMutable.
  • The implementation class derives from (or layout-extends) the public ::AudioBuffer C struct so that a Methcla_Resource* returned from construct casts cleanly to AudioBuffer* on the consumer side.
  • Constructor parses the OSC args (i:num_channels i:num_frames i:sample_rate), allocates the AudioBuffer struct + channel arrays via methcla_host_alloc_aligned.
  • Destructor frees channel arrays and the struct.

Engine implementation notes

  • Resource pool: internally a fixed array of an engine-private struct (ResourceEntry or similar) at engine init; one entry per Methcla_ResourceId.
  • All per-resource fields are plain types (no atomics); state, refcount, free_pending, and the Methcla_Resource* are RT-only.
  • NRT work runs on the engine's existing worker thread pool (Utility::WorkerThread, kNumWorkerThreads = 2); the NRT↔RT command FIFOs are the existing m_worker->sendToWorker / sendFromWorker channels, which already handle multi-producer enqueue via internal locking when needsLock = true.
  • Multi-worker caveat for plugin authors: Resource-def callbacks (construct, destroy, and the perform callback passed to perform_with_resources) run on the worker pool and can execute concurrently across different worker threads for different resources. The engine's own NRT-side APIs (methcla_host_alloc, methcla_host_free, the NRT→RT command channels) are thread-safe. Plugin authors who introduce plugin-side shared state (e.g., a static cache, a global file-handle pool) must synchronize that state themselves. The engine does not serialize NRT callbacks.
  • Notifications follow the existing Notification / NodeNotification pattern in EngineImpl.hpp — define ResourceReadyNotification, ResourceErrorNotification, ResourceDestroyedNotification.
  • Synth allocation layout is unchanged at the engine level (no engine-side per-synth resource binding table; the plugin owns its refs).
  • URI comparison in resource_acquire is a strcmp; interning at register_resource_def time (so subsequent comparisons are pointer-equality) is a possible future optimization.

Engine shutdown ordering

  1. Stop accepting new OSC.
  2. Free all nodes (synth destructors release their resource refs).
  3. For each remaining Live resource, force-destroy via NRT.
  4. Wait for any in-flight Constructing resources to finish; immediately destroy.
  5. Drain NRT command FIFOs.
  6. Tear down resource pool.

Resource classes remain valid for the lifetime of the engine that registered them. Per-plugin unload is not supported in this iteration.

CONTEXT.md updates (apply when implementing)

  • Add Resource entry: "A plugin-registered shared data object managed by the Engine, identified by an integer id and a type URI." _Avoid_: instance (a Synth is an instance; a Resource is the thing the plugin allocates).
  • Add ResourceDef entry mirroring SynthDef: "A Resource type registered by a Plugin. Describes the constructor, destructor, and interface URI for one kind of shared Resource." _Avoid_: resource class, resource type.
  • Add AudioBuffer entry: "A multi-channel sample-data Resource. Used for recording and playback by Synths." _Acceptable shorthand: buffer, in code.
  • Narrow AudioBus _Avoid_ from channel, buffer, bus (acceptable shorthand in code) to channel, bus (acceptable shorthand in code)buffer drops out (it now names a distinct concept).

Other documentation updates (apply when implementing)

Human-facing:

  • docs/osc-api.md — document the /resource/* verbs and the /node/set i:value form for resource ports.
  • docs/architecture.md — extend the runtime / routing overview to include Resources alongside Synths and AudioBuses.
  • docs/usage.md — add an example showing alloc → bind → use → free of an AudioBuffer with a record + play synth pair.
  • CHANGELOG.md — entry under ## [Unreleased], per the project conventions in CLAUDE.md.

Agent-facing:

  • CLAUDE.md "Domain and API references" — add a pointer to wherever the resource lifecycle lands (a section of architecture.md/osc-api.md, or a new doc).

ADR drafts

To file in docs/adr/ (number assigned at file-time) when the implementation PR lands.

RT-only mutation of resource state

Methcla's RT thread owns OSC dispatch, runs every state transition through EnvironmentImpl::process, and is responsible for synth destruction in Node::free (src/Methcla/Audio/Node.cpp:57-64). Every event that mutates a resource's engine-tracked state — creation, refcount changes from synth bind/unbind, free request, completion of NRT construct/destroy — originates from RT. The per-resource state, refcount, and free_pending fields are therefore single-writer (RT) and need no atomic synchronization. NRT is a pure async-callback for the plugin's construct/destroy and never reads or writes the resource's engine-tracked state directly; it returns results to RT via the existing NRT→RT command FIFO.

An alternative protocol that assumes RT-acquires + NRT-frees and uses an atomic refcount plus a dying flag with a seq_cst Dekker handshake was considered. Methcla's threading topology makes that machinery unnecessary. A future maintainer may try to "fix" the apparent absence of atomics — they should not. NRT use of a resource is always bracketed by methcla_world_perform_with_resources, whose RT-side acquire/release brackets the NRT callback, so the only mutator of refcount remains RT.

Mutability is informational

A Methcla_ResourceDef declares its mutability as kMethcla_ResourceMutable or kMethcla_ResourceImmutable. Both values are informational: the declaration documents the class's contract for consumers and for plugin authors implementing other classes, but the engine does not enforce write rejection. There is no read/write parameter on methcla_world_resource_acquire or methcla_world_perform_with_resources — the engine has no API surface at which to distinguish a read from a write.

Engine-side enforcement was considered: an alternative API would add a bool writable parameter to acquire and to perform_with_resources, with the engine rejecting writable acquires on Immutable classes. Rejected because (i) the use cases for Immutable resources (wavetables, lookup tables, FFT plans) are programmatically immutable — their published interface struct typically has no setter methods, so there's nothing the consumer could write even if it acquired with write intent; the contract is enforced at interface-design time, not at acquire time. (ii) Even with the parameter, enforcement would be best-effort — a consumer that lies about write-intent and casts away const defeats it. (iii) Adding the parameter inflates the API surface across every call site for a guarantee that doesn't actually hold.

For Mutable classes, race protection is layered: process-into-fresh-slot as the canonical safe pattern, perform_with_resources(..., exclusive=true) as a fail-fast check, and RT-side discipline (node ordering) for record-synth-style writes. Strict reader/writer enforcement on Mutable classes was also considered and rejected because it would break the motivating use case: a record synth (writer) and one or more play synths (readers) sharing the same buffer within a single audio pass.

Related issues

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions