moondsp

MoonBit DSP audio engine

moonbit
audio
moon add dowdiness/moondsp@0.5.1
Download zip
Author
Version
0.5.1
License
Apache-2.0
Last updated
2 months ago
Downloads
51

Dependencies

README

#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

moon check && moon test # type-check + run the full test suite moon build --target wasm-gc # build for browser moon run cmd/main # run CLI entry point

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 = T::oscillator(T::constant(2.0), Waveform::Sine)
let freq = range(lfo, 200.0, 400.0)
let carrier = T::oscillator(freq, Waveform::Sine)
let filtered = T::biquad(carrier, BiquadMode::LowPass, 800.0, 1.0)
T::output(T::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 stack(s("bd sd"), note("60 64")), 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 (?).

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.

Bridgescheduler/ 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 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

moon check # type-check moon test # run the full test suite moon test -p dowdiness/moondsp # run integration tests against the facade (root package only) moon test -p pattern # run pattern-engine tests only moon info && moon fmt # regenerate interfaces + format (run before committing) moon bench --release -p graph -f graph_benchmark.mbt # run performance benchmarks npm run test:browser # Playwright browser-integration tests (builds wasm-gc first)

The project follows an incremental edit rule: run moon check after every file edit, 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, collabPlannedREPL, CLAP plugins, CRDT multi-user

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

#
CompiledDspHotSwap

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

This first Phase 2 slice supports swapping between already-compiled mono graphs with an optional equal-power crossfade. It does not migrate internal node state between graphs.

#
CompiledDspTopologyController

Mono topology-edit wrapper that recompiles authoring nodes and stages the replacement through HotSwapGraph.

This first slice keeps edits narrow and deterministic: only ReplaceNode and RewireInput frames are supported, plus narrow unary InsertNode and DeleteNode frames on the mono path. Only one staged topology replacement may be pending at a time.

#
CompiledStereoDsp

Executable terminal-stereo graph compiled from DspNodes.

#
CompiledStereoDspHotSwap

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

This first stereo parity slice supports swapping between already-compiled terminal-stereo graphs with an optional equal-power crossfade. It does not migrate internal node state between graphs.

#
CompiledStereoDspTopologyController

Terminal-stereo topology-edit wrapper that recompiles authoring nodes and stages the replacement through HotSwapGraph.

Supports the same edit kinds as the mono controller (all six variants of GraphTopologyEdit). Only one staged topology replacement may be pending at a time.

#
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).

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

Design decision: DspNode uses a flat struct with generic fields (value0-value3) rather than per-kind structs or a tagged union with payloads. This is deliberate:

  1. Flat memory layout — all nodes are the same size, enabling fixed-array storage without boxing. Critical for zero-allocation audio processing.
  2. Copy-on-update — node_with_value0/value1/delay_samples create updated copies without knowing the node kind, supporting runtime parameter changes.
  3. Serialization — uniform layout simplifies graph serialization for hot-swap and topology editing.

The cost is that field semantics depend on kind — see each constructor (e.g. DspNode::oscillator, DspNode::biquad) for the field mapping.

#
DspNodeKind

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.

#
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 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, all delegating to .0.apply_control_impl() / .0.apply_controls_impl(). This is irreducible boilerplate under the current type system.

#
GraphDebuggable

Capability trait for debug validation of compiled DSP graphs.

Both mono and stereo compiled graphs support enabling debug validation mode, which checks node inputs and state before each process call.

#
GraphIndexMap

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

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

#
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

#
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? Used by ControlBindingBuilder::build() to validate bindings at graph construction time. Does not check value-domain constraints.

#
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