Sign in

    moondsp

    MoonBit DSP audio engine

    moonbit
    audio
    Download zip
    Author
    Version
    0.6.0
    License
    Apache-2.0
    Last updated
    last month
    Downloads
    95

    #moondsp

    A live-codable DSP audio engine written in MoonBit, targeting browser AudioWorklet via wasm-gc. Patterns describe what plays when; DSP graphs describe how it sounds.

    moondsp combines a Strudel/TidalCycles-inspired pattern algebra with a compiled signal-processing graph, a polyphonic voice pool, and a browser AudioWorklet runtime — all in one codebase, all in MoonBit.

    #Quick start

    NEW_MOON_MOD=0 moon check && NEW_MOON_MOD=0 moon test # type-check + run tests NEW_MOON_MOD=0 moon build --target wasm-gc # build for browser NEW_MOON_MOD=0 moon run cmd/main # run CLI entry point scripts/build-clap-prototype.sh # build Linux CLAP prototype shared object scripts/smoke-clap-prototype.sh # dlopen/process smoke test for the prototype scripts/validate-clap-prototype.sh # build + run clap-validator for the prototype

    NEW_MOON_MOD=0 keeps Moon from auto-migrating the repository's hand-maintained moon.mod.

    To hear it in the browser, open web/index.html after building. The AudioWorklet loads the compiled wasm-gc module and drives the DSP graph in real time.

    #What moondsp can do today

    DSP primitives — sine/saw/square/triangle oscillators, white noise, ADSR envelopes, biquad filters (LPF/HPF/BPF), delay lines with feedback, gain, mix, hard clip, equal-power pan, and parameter smoothing. All zero-allocation in the audio thread.

    Compiled graph runtime — declare a signal graph as an array of DspNode values, compile it into a topologically sorted execution plan, and process 128 samples per block at 48 kHz. Supports hot-swap (equal-power crossfade between graphs), topology editing (insert/delete/replace nodes at runtime), and mono-to-stereo routing.

    Finally Tagless DSP algebra — the same graph definition works as both a concrete AST for optimization and a trait-driven interpretation for extensibility:

    ///|
    fn[T : FilterSym] exit_deliverable() -> T {
    let lfo = DspSym::oscillator(ArithSym::constant(2.0), Waveform::Sine)
    let freq = range(lfo, 200.0, 400.0)
    let carrier = DspSym::oscillator(freq, Waveform::Sine)
    let filtered = T::biquad(carrier, BiquadMode::LowPass, 800.0, 1.0)
    DspSym::output(DspSym::gain(filtered, 0.3))
    }

    This compiles into an FM synthesis patch: a 2 Hz LFO sweeps a carrier between 200–400 Hz through a low-pass filter.

    Polyphonic voice pool — 32+ simultaneous voices with priority-based stealing (idle > oldest releasing > oldest active), generation-tagged handles for safe note control, two-stage silence detection (ADSR idle AND output buffer silent), and per-voice equal-power pan mixed to stereo. No allocation during process().

    Pattern engine — a standalone pattern/ package implementing Strudel's core model: patterns are query functions over rational-time arcs, producing events with control maps. Combinators such as silence, pure, fast, slow, rev, sequence, stack, and every compose into expressive rhythmic structures:

    // C major triad played twice per cycle
    sequence([note_name("c3"), note_name("e3"), note_name("g3")]).fast(Rational::from_int(2))

    Querying this over one cycle produces 6 events with exact rational time boundaries — no floating-point drift.

    For incremental editing, the pattern package also includes an identity-bearing authoring document that tracks stable node identities and revisions, then lowers back to the same runtime query model.

    Mini-notation parser — the mini/ package turns a short text string into a Pat[ControlMap], so you can write s("bd sd hh sd") or note("60 64 67"), combine sources with Strudel-style $: stack lines ($: s("bd sd") / $: note("60 64")) or the older stack(s("bd sd"), note("60 64")) form, and chain methods like .fast(n), .slow(n), .rev(), .degradeBy(p), .cutoff(f), .gain(g), .pan(p), .every(n, f), and .jux(f). Inside the string, sequences support sub-groups ([a b]), comma-stacked layers, Euclidean rhythms (bd(3,8)), step replicate/stretch (*n, /n), and 50%-drop (?). Authoring callers can use MiniAuthoringPipeline for an incr-backed text → PatternDoc → lowered snapshot pipeline; this currently wraps whole-document parsing and reuses stable subtrees/lowering-cache entries after parse, rather than doing token-level incremental parsing. See docs/mini-notation.md for the current syntax summary.

    Pattern → DSP scheduler — the scheduler/ package drives a BoundVoicePool from a Pat[ControlMap]: it converts the pattern's event stream into note on/off calls while the pool owns the ControlBindingMap proven against its current template. PatternScheduler::process_block is the one call that turns patterns into audio.

    #How it works

    The engine has two independent layers connected by a control map:

    Pattern Engine DSP Engine Pat.query(arc) CompiledDsp.process(ctx, buf) | | v v Array[Event[ControlMap]] BoundVoicePool.process(ctx, L, R) | ^ +-- { note: 60, cutoff: 800 } -----+ PatternScheduler.process_block

    Pattern layer operates at "human time" — rational fractions of musical cycles. It produces events describing what should happen.

    DSP layer operates at "audio time" — 128 samples per callback at 48 kHz (2.67 ms budget). It compiles declarative node graphs into flat execution plans and runs them without allocation.

    Bridge — scheduler/ connects the two: PatternScheduler::process_block queries a Pat[ControlMap] over the current block's time arc, turns events into bound-pool note on/off calls, and lets BoundVoicePool resolve control-map entries through the binding map attached to its current template.

    #Repository layout

    ./ Library public API facade (`moondsp.mbt` re-exports from dsp/, graph/, voice/, identity/) dsp/ DSP primitives, tagless algebra, pan math graph/ Compiled graph runtime, topology editing, hot-swap, control binding voice/ Polyphonic voice pool with priority stealing identity/ Stable ID wrappers and revision tokens for incremental editing pattern/ Pattern engine: rational time, combinators, control maps, authoring docs mini/ Mini-notation parser: text → Pat[ControlMap] song/ Long-form section scaffold with identity TimeScope scheduler/ Pattern scheduler: bridges pattern events to voice pool browser/ AudioWorklet integration (wasm-gc/js exports) browser_test/ Browser-integration test wrapper clap_engine/ Native CLAP synth engine core (MoonBit host-facing wrapper) clap_host/ Primitive handle bridge for the C CLAP shim clap_plugin/ CLAP prototype payload package and C ABI shim web/ Browser demo UI (HTML + AudioWorklet processor) cmd/main/ CLI entry point docs/ Architecture blueprint, technical reference, performance snapshots

    The pattern/ package has zero dependency on the DSP layers — it compiles and tests independently.

    #Performance

    The audio budget at 128 samples / 48 kHz is 2.67 ms per block. The graph runtime is designed around that budget: a single compiled voice (oscillator
    • filter + delay + ADSR) processes in the low-microsecond range, and 32 simultaneous FM voices comfortably fit inside the block. Compilation and hot-swap crossfades are also microsecond-scale, so graphs can be rebuilt or swapped between blocks without audible glitches.

    For measured numbers, see the dated snapshots under docs/performance/ (new measurements go in new files — older snapshots are preserved rather than overwritten, so you can see drift over time).

    #Development

    NEW_MOON_MOD=0 moon check --target all --deny-warn # type-check all targets, fail on warnings NEW_MOON_MOD=0 moon test --target all --deny-warn # run all tests on all targets NEW_MOON_MOD=0 moon test -p dowdiness/moondsp # root facade integration tests NEW_MOON_MOD=0 moon test -p pattern # pattern-engine tests NEW_MOON_MOD=0 moon info && NEW_MOON_MOD=0 moon fmt # regenerate interfaces + format NEW_MOON_MOD=0 moon bench --release -p dowdiness/moondsp/graph -f graph_benchmark.mbt npm run test:browser # Playwright browser-integration tests (builds wasm-gc first)

    The project follows an incremental edit rule: run NEW_MOON_MOD=0 moon check after every file edit and fix errors before proceeding.

    #Documentation

    Start at the docs index, which groups material by audience:

    • Technical reference — node types, parameter slots, runtime control surface (authoritative for graph runtime-control behavior)
    • Next actions — active handoff list for future sessions and API-hardening priorities
    • Blueprint — full architecture vision, design principles, roadmap
    • Performance snapshots — dated benchmark results (new measurements go in new files)
    • Architecture decisions — short ADRs distilling why the codebase looks the way it does (each links to the archived plan/spec)
    • CLAUDE.md — project map and conventions for contributors

    #Project status

    PhaseStatusSummary
    0 — Platform proofCompleteMoonBit wasm-gc runs in browser AudioWorklet
    1 — DSP primitivesCompleteOscillators, filters, envelopes, delay, gain, mix, clip, pan
    2 — Graph compilerCompleteCompiled graphs, hot-swap, topology editing, stereo
    3 — Voice managementComplete32+ voice pool with priority stealing and stereo mixdown
    4 — Pattern engineCompleteRational time, 8 combinators, ControlMap output
    5 — Pattern × DSPCompletescheduler/ + mini/ wire pattern events to voice allocation
    6 — incr integrationIn progressStable identity plus initial pattern/song authoring groundwork
    7+ — UI, native, collabPrototypeBrowser live UI and CLAP prototype available; collaboration planned

    #License

    Apache-2.0

    Adsr

    Stateful ADSR envelope generator.

    ArithSym

    Core algebra for the Finally Tagless DSP representation. Downstream packages implement this trait to provide different interpretations (evaluation, code generation, pretty printing, etc.).

    AudioBuffer

    Thin wrapper around FixedArray[Double] for DSP block processing.

    Biquad

    Stateful biquad filter using Direct Form II Transposed processing.

    BiquadMode

    Supported biquad response shapes for the Phase 1 filter primitive.

    BoundVoicePool

    BoundVoicePoolError

    ChannelSpec

    Clip

    Stateless hard-clipping processor for explicit range limiting.

    CompiledDsp

    Executable buffer-based DSP graph compiled from DspNodes.

    Public facade wrapper; implementation lives in graph/internal/runtime.

    CompiledDspHotSwap

    Block-boundary mono graph hot-swap wrapper for CompiledDsp.

    Public facade wrapper; implementation lives in graph/internal/staging.

    CompiledDspTopologyController

    Mono topology-edit wrapper that recompiles authoring nodes and stages the replacement through the internal hot-swap graph.

    Public facade wrapper; implementation lives in graph/internal/staging.

    CompiledStereoDsp

    Executable terminal-stereo graph compiled from DspNodes.

    Public facade wrapper; implementation lives in graph/internal/runtime.

    CompiledStereoDspHotSwap

    Block-boundary stereo graph hot-swap wrapper for CompiledStereoDsp.

    Public facade wrapper; implementation lives in graph/internal/staging.

    CompiledStereoDspTopologyController

    Terminal-stereo topology-edit wrapper that recompiles authoring nodes and stages the replacement through the internal hot-swap graph.

    Public facade wrapper; implementation lives in graph/internal/staging.

    CompiledTemplate

    Topology artifact: template snapshot paired with its optimize_graph result. Holds the minimum needed to answer post-optimization topology questions ("is this node live?", "how many ADSRs were eliminated?") without allocating runtime buffers or per-voice state.

    WHY a separate artifact from CompiledDsp: CompiledDsp conflates topology with runtime state. Binding validation and pool-level orphan gates only need topology — making that a lightweight standalone type avoids paying for buffers we never use, and keeps the API mono/stereo-agnostic (the same CompiledTemplate shape applies to any graph variant).

    This public facade keeps Array[DspNode] -> CompiledTemplate as the canonical authoring/runtime crossing while the implementation lives in graph/internal/template over the raw graph model.

    ControlBinding

    ControlBindingBuilder

    ControlBindingError

    ControlBindingMap

    Proven-valid control bindings. Validated against a specific CompiledTemplate at build time (bounds + slot compatibility + orphan detection + key uniqueness).

    No public constructor — only reachable through ControlBindingBuilder::build().

    WARNING: A ControlBindingMap's validity is tied to the template it was built against. After VoicePool::set_template swaps to a new template, bindings validated against the prior template remain type-level valid but may silently retarget the wrong kind of node or no-op against nodes the new template's optimize_graph eliminated. Rebuild the ControlBindingMap whenever the template changes. Structural staleness detection is tracked as a follow-up.

    DelayLine

    Stateful integer-sample delay line backed by a circular buffer.

    DelaySym

    DemoSource

    Encapsulates the mutable oscillator + noise state used by the browser demo tick functions. Each WASM entry point (root, browser/, browser_test/) creates its own instance so module-level mutable globals stay per-package.

    DspContext

    Shared execution context for block-based DSP processing.

    DspNode

    Flat graph node representation for compiled DSP graphs.

    DspNodeKind

    Public graph facade wrappers for the raw graph model in graph/internal/model.

    These wrappers preserve source compatibility for callers that write @graph.DspNode::constant(...) while keeping the extracted model package hidden behind the facade.

    Minimal Phase 2 graph node kinds for compiled mono DSP graphs.

    DspSym

    EnvStage

    Public ADSR stage names for diagnostics and tests.

    FilterSym

    Gain

    Stateless in-place gain processor for Phase 1 block processing.

    GraphBuilder

    A tagless interpretation that builds an Array[DspNode] graph.

    Each GraphBuilder value carries a mutable node array and the index of the "current" node within that array. Source nodes (constant, noise, adsr) start a fresh array, unary ops append to the existing array, and binary ops merge two arrays when they originate from different sources.

    GraphCompileError

    Typed rejection reason for graph compilation.

    Node indices reported by public compile_result entry points are original authoring indices when they can be mapped from the analyzed template.

    GraphControl

    Runtime control message for a compiled graph.

    GraphControlError

    Failure reason for runtime graph control APIs.

    GraphControlKind

    Unified runtime control kinds for compiled graphs.

    GraphControllable

    Capability trait for runtime control of compiled DSP graphs.

    Both mono and stereo compiled graphs, their hot-swap wrappers, and their topology controllers share the same control interface. This trait captures that shared interface so consumer code can be written generically over any controllable graph.

    The process method is intentionally excluded because mono and stereo graphs have different output arity (one buffer vs. two).

    Visibility: pub, not pub(open). The trait is intended for the six canonical wrapper types defined in this package; external types should compose with these wrappers rather than implement the trait directly. If a downstream use case for an external impl emerges, the contract to honour is: (a) apply_control returns the same GraphControlError variants the canonical wrappers do, (b) apply_controls is transactional — validate the whole batch first, then apply, leaving the graph unchanged on the first error.

    WHY 12 near-identical impl blocks below: MoonBit has no blanket impls or default trait methods. Each of the 6 wrapper types (CompiledDsp, CompiledStereoDsp, CompiledDspHotSwap, CompiledStereoDspHotSwap, CompiledDspTopologyController, CompiledStereoDspTopologyController) must declare both methods individually. The compiled wrappers delegate to graph/internal/runtime; staging wrappers delegate to their local state. This is irreducible boilerplate under the current type system.

    GraphDebuggable

    GraphIndexMap

    Stable-ID to authoring-index lookup produced by GraphTemplateDoc.

    Public facade wrapper; implementation lives in graph/internal/authoring.

    GraphParamSlot

    Runtime-updatable numeric graph parameter slots.

    GraphTemplateDoc

    Identity-bearing authoring wrapper for graph templates.

    Runtime graph compilation remains index-based. GraphTemplateDoc owns the stable authoring IDs and exposes GraphIndexMap at the boundary where controls, bindings, and topology edits are translated to existing indices.

    Public facade wrapper; implementation lives in graph/internal/authoring.

    GraphTemplateDocError

    GraphTopologyEdit

    Declarative topology edit applied in authoring order before recompilation.

    Each variant carries exactly the fields it needs — no sentinel values.

    GraphTopologyInputSlot

    Input slot selector for fixed-length topology rewiring.

    GraphTopologyQueueError

    Failure reason for result-typed topology queue APIs.

    GraphValidationError

    HotSwapQueueError

    Failure reason for result-typed hot-swap queue APIs.

    Mix

    Stateless in-place mixer for combining audio buffers.

    Noise

    Deterministic white-noise source with explicit RNG state.

    NoteGate

    Gate timing selected by the instrument. Release continues after gate-off.

    Oscillator

    Stateful oscillator for the first reusable Phase 1 DSP primitive.

    Pan

    Stateless equal-power pan processor for mono-to-stereo routing.

    ParamSmoother

    Stateful one-pole smoother for click-free control changes.

    Stereo

    StereoDelaySym

    StereoFilterSym

    StereoReverb

    Wet-only stereo room reverb with fixed delay lengths and preallocated state.

    The network is eight parallel damped feedback combs followed by four serial allpasses per channel. Delay lengths use the published Freeverb tunings, scaled once for the requested sample rate. decay_seconds is the low- frequency T60; damping is a normalized high-frequency loss coefficient.

    StereoSym

    VoiceControlError

    VoiceHandle

    Generation-tagged voice handle. WHY generation counter: slot indices are reused after voice stealing. Without a generation tag, a stale handle from a stolen note could accidentally gate_off or pan the wrong voice occupying that slot. Every note_on increments the slot's generation; all operations compare the handle's generation against the slot's current generation.

    VoicePool

    VoicePoolError

    Failure modes for VoicePool construction and template replacement. Mirrors BoundVoicePoolError minus the Binding(...) variant (VoicePool has no bindings).

    VoiceState

    Waveform

    Supported oscillator waveforms for the Phase 1 source primitive.

    effective_sample_count

    Compute the effective number of samples to process, bounded by both the context block size and the buffer length.

    exit_deliverable

    The Phase 2 exit deliverable: sine(2).range(200,400).sine().lpf(800,1).out() An LFO at 2 Hz modulates the frequency of a carrier oscillator between 200-400 Hz, then filtered through a low-pass biquad at 800 Hz.

    is_finite

    fn is_finite(value : Double) -> Bool

    Check whether a floating-point value is finite (not NaN, not Inf).

    is_finite_positive

    fn is_finite_positive(value : Double) -> Bool

    Check whether a floating-point value is finite and strictly positive.

    lin_map

    fn[T :
    ArithSym
    ] lin_map(input : T, in_lo : Double, in_hi : Double, out_lo : Double, out_hi : Double) -> T

    Maps [in_lo, in_hi] -> [out_lo, out_hi]. Only requires ArithSym. Precondition: in_lo != in_hi (division by zero otherwise).

    max_feedback_amount

    fn max_feedback_amount() -> Double

    mono_shape

    fn mono_shape() -> Int

    Bridge to existing MONO_SIGNAL_SHAPE constant (value 0).

    node_accepts_slot

    Structural check: does this node kind accept SetParam on the given slot?

    pan_left_gain

    fn pan_left_gain(position : Double) -> Double

    Equal-power left-channel gain for a pan position in [-1.0, 1.0]. -1.0 = hard left (gain 1.0), 0.0 = center (~0.707), 1.0 = hard right (gain 0.0). Non-finite positions return 0.0.

    pan_right_gain

    fn pan_right_gain(position : Double) -> Double

    Equal-power right-channel gain for a pan position in [-1.0, 1.0]. -1.0 = hard left (gain 0.0), 0.0 = center (~0.707), 1.0 = hard right (gain 1.0). Non-finite positions return 0.0.

    range

    fn[T :
    ArithSym
    ] range(input : T, lo : Double, hi : Double) -> T

    Maps [-1, 1] -> [lo, hi]. Only requires ArithSym.

    replay

    Walk a DspNode array and reconstruct the DSP graph through the tagless API, enabling round-trip testing (GraphBuilder -> nodes -> replay -> Eval). Returns None for empty arrays or invalid node references.

    sanitize_buffer

    fn sanitize_buffer(buffer :
    AudioBuffer
    , sample_count : Int) -> Int

    Replace non-finite samples (NaN, Inf) with 0.0 in-place. Returns the number of samples replaced. This is the output firewall — the last line of defense before samples leave the DSP engine.

    stereo_shape

    fn stereo_shape() -> Int

    Bridge to existing STEREO_SIGNAL_SHAPE constant (value 1).

    Source Files