aboutsummaryrefslogtreecommitdiff
path: root/mod
diff options
context:
space:
mode:
Diffstat (limited to 'mod')
-rw-r--r--mod/biquad.c202
-rw-r--r--mod/buffer.c281
-rw-r--r--mod/delay.c233
-rw-r--r--mod/expression.c500
-rw-r--r--mod/fs.c103
-rw-r--r--mod/gain.c164
-rw-r--r--mod/interp.c257
-rw-r--r--mod/keycallback.c147
-rw-r--r--mod/ladspa.c604
-rw-r--r--mod/lookback.c106
-rw-r--r--mod/map.h49
-rw-r--r--mod/mixer.c114
-rw-r--r--mod/mutil.h69
-rw-r--r--mod/null.c76
-rw-r--r--mod/nullsink.c111
-rw-r--r--mod/oscil.c438
-rw-r--r--mod/pan.c187
-rw-r--r--mod/passthrough.c82
-rw-r--r--mod/playback.c342
-rw-r--r--mod/sample.c180
-rw-r--r--mod/sample.h33
-rw-r--r--mod/serial.c511
-rw-r--r--mod/spring.c173
-rw-r--r--mod/timer.c152
-rw-r--r--mod/udp.c391
25 files changed, 5505 insertions, 0 deletions
diff --git a/mod/biquad.c b/mod/biquad.c
new file mode 100644
index 0000000..6591d87
--- /dev/null
+++ b/mod/biquad.c
@@ -0,0 +1,202 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <stdlib.h>
+#include <stdbool.h>
+#include <math.h>
+#include <lauxlib.h>
+#include "melisma.h"
+#include "mutil.h"
+
+#define LOWPASS_TYPENAME "biquad LPF"
+#define LOWPASS_DEFAULT_CUTOFF 4000
+#define LOWPASS_DEFAULT_RESONANCE 0.5
+
+struct biquad;
+
+typedef void (*recomp_fn)(struct biquad*);
+
+struct biquad
+{
+ ml_channel *out;
+ recomp_fn recomp;
+ double x_1,x_2,y_1,y_2;
+ double xc_0,xc_1,xc_2;
+ double yc_1,yc_2;
+ double Fs;
+ double f0;
+ double Q;
+};
+
+static inline void lowpass_recomp(struct biquad *bq)
+{
+ double w0,cosw0,sinw0,alpha,b0,b1,b2,a0,a1,a2;
+ w0 = 2*M_PI*bq->f0/bq->Fs;
+ cosw0 = cos(w0);
+ sinw0 = sin(w0);
+ alpha = sinw0/(2*bq->Q);
+ b0 = (1 - cosw0)/2;
+ b1 = 1 - cosw0;
+ b2 = (1 - cosw0)/2;
+ a0 = 1 + alpha;
+ a1 = -2*cosw0;
+ a2 = 1 - alpha;
+ bq->xc_0 = b0/a0;
+ bq->xc_1 = b1/a0;
+ bq->xc_2 = b2/a0;
+ bq->yc_1 = a1/a0;
+ bq->yc_2 = a2/a0;
+}
+
+static void proc(const ml_proc_args *args)
+{
+ struct biquad bq = *(struct biquad*) args->userp;
+
+ float *x = args->in[0];
+ float *y = args->out[0];
+
+ for (size_t i = 0; i < args->nframes; i++)
+ {
+ y[i] = bq.xc_0*x[i]
+ + bq.xc_1*bq.x_1
+ + bq.xc_2*bq.x_2
+ - bq.yc_1*bq.y_1
+ - bq.yc_2*bq.y_2;
+
+ bq.x_2 = bq.x_1;
+ bq.x_1 = x[i];
+ bq.y_2 = bq.y_1;
+ bq.y_1 = y[i];
+ }
+
+ *(struct biquad*) args->userp = bq;
+}
+
+static int l_lowpass_init(lua_State *s)
+{
+ ml_channel *src = mutil_get_channel(s,1);
+
+ struct biquad *bq = calloc(1,sizeof(*bq));
+ bq->recomp = lowpass_recomp;
+ bq->Fs = ml_sample_rate();
+ bq->f0 = LOWPASS_DEFAULT_CUTOFF;
+ bq->Q = LOWPASS_DEFAULT_RESONANCE;
+ bq->recomp(bq);
+
+ ml_node *node = ml_create_node(proc,bq);
+ ml_label_node(node,"lowpass");
+
+ ml_channel *dst = ml_add_input(node);
+ ml_connect(src,dst);
+
+ bq->out = ml_add_output(node);
+
+ *(void**) lua_newuserdata(s,sizeof(void*)) = bq;
+ luaL_setmetatable(s,LOWPASS_TYPENAME);
+
+ return 1;
+}
+
+static int l_lowpass_resonance(lua_State *s)
+{
+ struct biquad *bq = *(void**) luaL_checkudata(s,1,LOWPASS_TYPENAME);
+ bq->Q = luaL_checknumber(s,2);
+ bq->recomp(bq);
+ *(void**) lua_newuserdata(s,sizeof(void*)) = bq;
+ luaL_setmetatable(s,LOWPASS_TYPENAME);
+ return 1;
+}
+
+static int l_lowpass_cutoff(lua_State *s)
+{
+ struct biquad *bq = *(void**) luaL_checkudata(s,1,LOWPASS_TYPENAME);
+ bq->f0 = luaL_checknumber(s,2);
+ bq->recomp(bq);
+ *(void**) lua_newuserdata(s,sizeof(void*)) = bq;
+ luaL_setmetatable(s,LOWPASS_TYPENAME);
+ return 1;
+}
+
+static int l_lowpass_out(lua_State *s)
+{
+ struct biquad *bq = *(void**) luaL_checkudata(s,1,LOWPASS_TYPENAME);
+ *(void**) lua_newuserdata(s,sizeof(void*)) = bq->out;
+ luaL_setmetatable(s,MELISMA_OUTPUT_CHANNEL_TYPENAME);
+ return 1;
+}
+
+int luaopen_melisma_biquad(lua_State *s)
+{
+/*** Digital biquad filters.
+@module biquad
+*/
+ lua_newtable(s);
+ int tbl = lua_gettop(s);
+
+/*** Lowpass filter.
+@type biquad.lowpass
+@usage
+
+ local mbiquad = require 'melisma.biquad'
+ local input = ...
+ local lowpass = mbiquad.lowpass.init(node, 2000, 0.6)
+ lowpass:resonance(1000)
+ lowpass:cutoff(0.25)
+*/
+ lua_newtable(s);
+ int lowpass_tbl = lua_gettop(s);
+
+/*** Create a lowpass filter.
+@static
+@function init
+@tparam channel input Input signal.
+@treturn biquad.lowpass The newly created lowpass filter.
+*/
+ lua_pushcfunction(s,l_lowpass_init);
+ lua_setfield(s,lowpass_tbl,"init");
+
+/*** Set filter resonance.
+@function lowpass:resonance
+@tparam number res Resonance.
+*/
+ lua_pushcfunction(s,l_lowpass_resonance);
+ lua_setfield(s,lowpass_tbl,"resonance");
+
+
+/*** Set filter cutoff frequency.
+@function lowpass:cutoff
+@tparam number cut Cutoff frequency.
+*/
+ lua_pushcfunction(s,l_lowpass_cutoff);
+ lua_setfield(s,lowpass_tbl,"cutoff");
+
+ lua_pushcfunction(s,l_lowpass_out);
+ lua_setfield(s,lowpass_tbl,MUTIL_OUTPUT_CHANNEL_FUNCTION);
+
+ luaL_newmetatable(s,LOWPASS_TYPENAME);
+ int lowpass_mt = lua_gettop(s);
+
+ lua_pushvalue(s,lowpass_tbl);
+ lua_setfield(s,lowpass_mt,"__index");
+
+ lua_pushvalue(s,lowpass_tbl);
+ lua_setfield(s,tbl,"lowpass");
+
+ lua_settop(s,tbl);
+ return 1;
+}
diff --git a/mod/buffer.c b/mod/buffer.c
new file mode 100644
index 0000000..64ba13e
--- /dev/null
+++ b/mod/buffer.c
@@ -0,0 +1,281 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <stdlib.h>
+#include <string.h>
+#include <lauxlib.h>
+#include "melisma.h"
+#include "mutil.h"
+
+#define BUFFER_TYPENAME "buffer"
+
+enum mode {CAPTURE,PLAYBACK,IDLE};
+
+struct buffer
+{
+ ml_channel *chan;
+ enum mode mode;
+ double phase;
+ double speed;
+ size_t len;
+ size_t cap;
+ float data[];
+};
+
+static size_t capture(struct buffer *b, const float *in, size_t n)
+{
+ size_t i = 0;
+
+ for (; i < n; i++)
+ {
+ b->data[b->len++] = in[i];
+
+ if (b->len == b->cap)
+ {
+ b->mode = IDLE;
+ b->phase = 0;
+ break;
+ }
+ }
+
+ return i;
+}
+
+static size_t playback(struct buffer *b, float *out, size_t n)
+{
+ size_t i = 0;
+
+ for (; i < n; i++)
+ {
+ float t = b->phase*b->len;
+ size_t i0 = (size_t) t;
+ size_t i1 = i0 + 1;
+ float ip = t - i0;
+
+ i0 %= b->len;
+ i1 %= b->len;
+
+ float s0 = b->data[i0];
+ float s1 = b->data[i1];
+
+ out[i] = s0 + ip*(s1 - s0);
+
+ b->phase += b->speed/b->len;
+
+ if (b->phase >= 1)
+ {
+ b->phase = 0;
+ b->mode = IDLE;
+ }
+ }
+
+ return i;
+}
+
+static size_t idle(struct buffer *b, float *out, size_t n)
+{
+ memset(out,0,sizeof(*out)*n);
+ return n;
+}
+
+static void proc(const ml_proc_args *args)
+{
+ struct buffer *b = args->userp;
+ const float *in = args->in[0];
+ float *out = args->out[0];
+ size_t n = args->nframes;
+
+ while (n > 0)
+ {
+ size_t d;
+
+ switch (b->mode)
+ {
+ case CAPTURE:
+ d = capture(b,in,n);
+ break;
+ case PLAYBACK:
+ d = playback(b,out,n);
+ break;
+ case IDLE:
+ d = idle(b,out,n);
+ break;
+ }
+
+ in += d;
+ out += d;
+ n -= d;
+ }
+}
+
+static int l_init(lua_State *s)
+{
+ float t = luaL_checknumber(s,1);
+ ml_channel *src = mutil_get_channel(s,2);
+
+ if (t <= 0)
+ {
+ luaL_error(s,"invalid buffer capacity %.2f\n",t);
+ }
+
+ size_t sr = ml_sample_rate();
+ size_t cap = t*sr;
+
+ struct buffer *b;
+ size_t size = sizeof(*b) + sizeof(*b->data)*cap;
+ b = calloc(1,size);
+
+ ml_node *node = ml_create_node(proc,b);
+ ml_label_node(node,"buffer");
+
+ ml_channel *dst = ml_add_input(node);
+ ml_connect(src,dst);
+
+ b->mode = IDLE;
+ b->len = 0;
+ b->phase = 0;
+ b->cap = cap;
+ b->chan = ml_add_output(node);
+
+ *(void**) lua_newuserdata(s,sizeof(void*)) = b;
+ luaL_setmetatable(s,BUFFER_TYPENAME);
+
+ return 1;
+}
+
+static int l_setmode(lua_State *s)
+{
+ int mode_i = lua_upvalueindex(1);
+ struct buffer *b = *(void**) luaL_checkudata(s,1,BUFFER_TYPENAME);
+ enum mode mode = lua_tointeger(s,mode_i);
+ b->phase = 0;
+ b->mode = mode;
+
+ if (mode == CAPTURE)
+ {
+ b->len = 0;
+ }
+
+ return 0;
+}
+
+static int l_setspeed(lua_State *s)
+{
+ struct buffer *b = *(void**) luaL_checkudata(s,1,BUFFER_TYPENAME);
+ double speed = luaL_checknumber(s,2);
+
+ if (speed <= 0)
+ {
+ luaL_error(s,"invalid speed %.2f",speed);
+ }
+
+ b->speed = speed;
+ return 0;
+}
+
+static int l_getlength(lua_State *s)
+{
+ struct buffer *b = *(void**) luaL_checkudata(s,1,BUFFER_TYPENAME);
+ size_t sr = ml_sample_rate();
+ lua_pushnumber(s,(float)b->len / (float)sr / b->speed);
+ return 1;
+}
+
+static int l_out(lua_State *s)
+{
+ struct buffer *b = *(void**) luaL_checkudata(s,1,BUFFER_TYPENAME);
+ *(void**) lua_newuserdata(s,sizeof(void*)) = b->chan;
+ luaL_setmetatable(s,MELISMA_OUTPUT_CHANNEL_TYPENAME);
+ return 1;
+}
+
+int luaopen_melisma_buffer(lua_State *s)
+{
+/*** Audio buffer for capture/playback.
+
+ local mbuffer = require 'melisma.buffer'
+ local mevent = require 'melisma.event'
+
+ local input = ...
+
+ -- Create a buffer with capacity of 5 seconds
+ -- Capture stops automatically when buffer is full.
+ local len = 5
+ local buffer = mbuffer.init(input, len)
+
+ -- Capture audio
+ buffer:capture()
+
+ -- Playback after capture 3 seconds
+ mevent.schedule(3, buffer.playback, buffer)
+
+@classmod buffer
+*/
+ lua_newtable(s);
+ int tbl = lua_absindex(s,lua_gettop(s));
+
+/*** Create an audio buffer.
+@static
+@function buffer.init
+@tparam channel input Input signal.
+@tparam number len Buffer capacity in seconds.
+@treturn buffer The newly created audio buffer.
+*/
+ lua_pushcfunction(s,l_init);
+ lua_setfield(s,tbl,"init");
+
+/*** Set playback speed.
+@function buffer.speed
+@tparam number speed Playback speed.
+*/
+ lua_pushcfunction(s,l_setspeed);
+ lua_setfield(s,tbl,"speed");
+
+/*** Get length of captured audio.
+@function buffer.length
+@treturn number Length of captured audio in seconds.
+*/
+ lua_pushcfunction(s,l_getlength);
+ lua_setfield(s,tbl,"length");
+
+/*** Start playback mode.
+@function buffer.playback
+*/
+ lua_pushinteger(s,PLAYBACK);
+ lua_pushcclosure(s,l_setmode,1);
+ lua_setfield(s,tbl,"playback");
+
+/*** Start capture mode.
+@function buffer.capture
+*/
+ lua_pushinteger(s,CAPTURE);
+ lua_pushcclosure(s,l_setmode,1);
+ lua_setfield(s,tbl,"capture");
+
+ lua_pushcfunction(s,l_out);
+ lua_setfield(s,tbl,MUTIL_OUTPUT_CHANNEL_FUNCTION);
+
+ luaL_newmetatable(s,BUFFER_TYPENAME);
+ int mt = lua_absindex(s,lua_gettop(s));
+
+ lua_pushvalue(s,tbl);
+ lua_setfield(s,mt,"__index");
+
+ lua_settop(s,tbl);
+ return 1;
+}
diff --git a/mod/delay.c b/mod/delay.c
new file mode 100644
index 0000000..44441cf
--- /dev/null
+++ b/mod/delay.c
@@ -0,0 +1,233 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <stdlib.h>
+#include <lauxlib.h>
+#include "melisma.h"
+#include "mutil.h"
+
+#define DELAY_TYPENAME "delay"
+
+#define MAX_DELAY 2.00f
+#define DEFAULT_DELAY 0.25f
+#define DEFAULT_GAIN 1.00f
+#define DEFAULT_FEEDBACK 0.25f
+#define DEFAULT_WETDRYMIX 0.50f
+
+#define CLAMP(v,min,max) ((v)<(min)?(min):((v)>(max)?(max):(v)))
+
+struct delay
+{
+ ml_channel *out;
+ float feedback;
+ float gain;
+ float wetdrymix;
+ float delay;
+ size_t len;
+ size_t write;
+ float *buffer;
+};
+
+static void proc(const ml_proc_args *args)
+{
+ struct delay *dl = args->userp;
+
+ size_t sr = ml_sample_rate();
+ size_t offset = sr*dl->delay;
+
+ const float *in = args->in[0];
+ float *out = args->out[0];
+
+ size_t read;
+ float dry,wet;
+
+ for (size_t i = 0; i < args->nframes; i++)
+ {
+ read = dl->write + dl->len - offset;
+
+ if (read >= dl->len)
+ {
+ read -= dl->len;
+ }
+
+ dry = in[i];
+ wet = dl->buffer[read]*dl->gain;
+
+ out[i] = wet*dl->wetdrymix + dry*(1 - dl->wetdrymix);
+ dl->buffer[dl->write++] = dry + wet*dl->feedback;
+
+ if (dl->write == dl->len)
+ {
+ dl->write = 0;
+ }
+ }
+}
+
+static void clamp(struct delay *dl)
+{
+ dl->delay = CLAMP(dl->delay,0,MAX_DELAY);
+ dl->gain = CLAMP(dl->gain,0,1);
+ dl->feedback = CLAMP(dl->feedback,0,1);
+ dl->wetdrymix = CLAMP(dl->wetdrymix,0,1);
+}
+
+static int l_init(lua_State *s)
+{
+ ml_channel *src = mutil_get_channel(s,1);
+
+ struct delay *dl = calloc(1,sizeof(*dl));
+
+ dl->delay = DEFAULT_DELAY;
+ dl->gain = DEFAULT_GAIN;
+ dl->feedback = DEFAULT_FEEDBACK;
+ dl->wetdrymix = DEFAULT_WETDRYMIX;
+
+ size_t sr = ml_sample_rate();
+ dl->len = sr*MAX_DELAY;
+ dl->buffer = calloc(1,dl->len*sizeof(dl->buffer[0]));
+
+ clamp(dl);
+
+ ml_node *node = ml_create_node(proc,dl);
+ ml_label_node(node,"delay");
+ ml_channel *dst = ml_add_input(node);
+ ml_connect(src,dst);
+
+ dl->out = ml_add_output(node);
+
+ *(void**) lua_newuserdata(s,sizeof(void*)) = dl;
+ luaL_setmetatable(s,DELAY_TYPENAME);
+
+ return 1;
+}
+
+static int l_out(lua_State *s)
+{
+ struct delay *dl = *(void**) luaL_checkudata(s,1,DELAY_TYPENAME);
+ *(void**) lua_newuserdata(s,sizeof(void*)) = dl->out;
+ luaL_setmetatable(s,MELISMA_OUTPUT_CHANNEL_TYPENAME);
+ return 1;
+}
+
+static int l_delay(lua_State *s)
+{
+ struct delay *dl = *(void**) luaL_checkudata(s,1,DELAY_TYPENAME);
+ float v = luaL_checknumber(s,2);
+ dl->delay = v;
+ clamp(dl);
+ *(void**) lua_newuserdata(s,sizeof(void*)) = dl;
+ luaL_setmetatable(s,DELAY_TYPENAME);
+ return 1;
+}
+
+static int l_gain(lua_State *s)
+{
+ struct delay *dl = *(void**) luaL_checkudata(s,1,DELAY_TYPENAME);
+ float v = luaL_checknumber(s,2);
+ dl->gain = v;
+ clamp(dl);
+ *(void**) lua_newuserdata(s,sizeof(void*)) = dl;
+ luaL_setmetatable(s,DELAY_TYPENAME);
+ return 1;
+}
+
+static int l_feedback(lua_State *s)
+{
+ struct delay *dl = *(void**) luaL_checkudata(s,1,DELAY_TYPENAME);
+ float v = luaL_checknumber(s,2);
+ dl->feedback = v;
+ clamp(dl);
+ *(void**) lua_newuserdata(s,sizeof(void*)) = dl;
+ luaL_setmetatable(s,DELAY_TYPENAME);
+ return 1;
+}
+
+static int l_wetdrymix(lua_State *s)
+{
+ struct delay *dl = *(void**) luaL_checkudata(s,1,DELAY_TYPENAME);
+ float v = luaL_checknumber(s,2);
+ dl->wetdrymix = v;
+ clamp(dl);
+ *(void**) lua_newuserdata(s,sizeof(void*)) = dl;
+ luaL_setmetatable(s,DELAY_TYPENAME);
+ return 1;
+}
+
+int luaopen_melisma_delay(lua_State *s)
+{
+ ml_check_version();
+
+/*** Digital delay line.
+@classmod delay
+*/
+ lua_newtable(s);
+ int tbl = lua_gettop(s);
+
+/*** Create a delay line.
+@static
+@function init
+@tparam channel input Input signal.
+@treturn delay Newly created delay line.
+*/
+ lua_pushcfunction(s,l_init);
+ lua_setfield(s,tbl,"init");
+
+/*** Set delay.
+@function delay
+@tparam number d delay in seconds.
+@treturn delay
+*/
+ lua_pushcfunction(s,l_delay);
+ lua_setfield(s,tbl,"delay");
+
+/*** Set gain.
+@function gain
+@tparam number g Gain (**TODO**: Use decibels).
+@treturn delay
+*/
+ lua_pushcfunction(s,l_gain);
+ lua_setfield(s,tbl,"gain");
+
+/*** Set feedback.
+@function feedback
+@tparam number f Feedback.
+@treturn delay
+*/
+ lua_pushcfunction(s,l_feedback);
+ lua_setfield(s,tbl,"feedback");
+
+/*** Set wet/dry mix.
+@function wetdrymix
+@tparam number wd Wet/dry mix.
+@treturn delay
+*/
+ lua_pushcfunction(s,l_wetdrymix);
+ lua_setfield(s,tbl,"wetdrymix");
+
+ lua_pushcfunction(s,l_out);
+ lua_setfield(s,tbl,MUTIL_OUTPUT_CHANNEL_FUNCTION);
+
+ luaL_newmetatable(s,DELAY_TYPENAME);
+ int mt = lua_gettop(s);
+
+ lua_pushvalue(s,tbl);
+ lua_setfield(s,mt,"__index");
+
+ lua_settop(s,tbl);
+ return 1;
+}
diff --git a/mod/expression.c b/mod/expression.c
new file mode 100644
index 0000000..1b78199
--- /dev/null
+++ b/mod/expression.c
@@ -0,0 +1,500 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <stdlib.h>
+#include <string.h>
+#include <stdbool.h>
+#include <assert.h>
+#include <lauxlib.h>
+#include "melisma.h"
+#include "mutil.h"
+
+#define EXPR_TYPENAME "expression"
+#define SCRIPT_TYPENAME "expression script"
+
+enum tag {ADD,SUB,MUL,DIV,IN,OUT,CONST,HALT};
+
+struct op
+{
+ enum tag tag;
+ float value;
+ size_t index;
+ size_t offset;
+ ml_channel *src;
+};
+
+struct script
+{
+ size_t len;
+ struct op ops[];
+};
+
+struct expr
+{
+ size_t num_inputs;
+ size_t *buf_lens;
+ size_t *cursors;
+ float **bufs;
+ size_t out_buf_len;
+ size_t out_cursor;
+ float *out_buf;
+ float *stack;
+ size_t stack_len;
+ size_t stack_p;
+ struct script *script;
+ ml_channel *out;
+};
+
+static size_t script_alloc_size(size_t len)
+{
+ return sizeof(struct script) + len*sizeof(struct op);
+}
+
+static struct script *push_script(lua_State *s, size_t len)
+{
+ struct script *script = lua_newuserdata(s,script_alloc_size(len));
+ luaL_setmetatable(s,SCRIPT_TYPENAME);
+ script->len = len;
+ return script;
+}
+
+static int l_const(lua_State *s)
+{
+ float value = luaL_checknumber(s,1);
+ struct script *script = push_script(s,1);
+ script->ops[0].tag = CONST;
+ script->ops[0].value = value;
+ return 1;
+}
+
+static int l_input(lua_State *s)
+{
+ ml_channel *src = mutil_get_channel(s,1);
+ size_t offset = luaL_optinteger(s,2,0);
+ struct script *script = push_script(s,1);
+ script->ops[0].tag = IN;
+ script->ops[0].src = src;
+ script->ops[0].offset = offset;
+ return 1;
+}
+
+static int l_output(lua_State *s)
+{
+ size_t offset = luaL_checkinteger(s,1);
+ struct script *script = push_script(s,1);
+ script->ops[0].tag = OUT;
+ script->ops[0].offset = offset;
+ return 1;
+}
+
+static int convert(lua_State *s, int i)
+{
+ if (luaL_testudata(s,i,SCRIPT_TYPENAME))
+ {
+ return i;
+ }
+
+ if (lua_isnumber(s,i))
+ {
+ lua_pushcfunction(s,l_const);
+ lua_pushvalue(s,i);
+ lua_call(s,1,1);
+ return lua_gettop(s);
+ }
+
+ const char *typename = lua_typename(s,lua_type(s,i));
+ luaL_error(s,"expected number or expression script, got %s",typename);
+
+ return 0;
+}
+
+static int binop(lua_State *s, int tag)
+{
+ int lhs_i = convert(s,1);
+ int rhs_i = convert(s,2);
+
+ struct script *lhs = luaL_checkudata(s,lhs_i,SCRIPT_TYPENAME);
+ struct script *rhs = luaL_checkudata(s,rhs_i,SCRIPT_TYPENAME);
+
+ size_t len = lhs->len + rhs->len + 1;
+ struct script *res = push_script(s,len);
+
+ memcpy(&res->ops[0],lhs->ops,sizeof(res->ops[0])*lhs->len);
+ memcpy(&res->ops[lhs->len],rhs->ops,sizeof(res->ops[0])*rhs->len);
+
+ res->ops[len-1].tag = tag;
+
+ return 1;
+}
+
+static int l_add(lua_State *s)
+{
+ return binop(s,ADD);
+}
+
+static int l_sub(lua_State *s)
+{
+ return binop(s,SUB);
+}
+
+static int l_mul(lua_State *s)
+{
+ return binop(s,MUL);
+}
+
+static int l_div(lua_State *s)
+{
+ return binop(s,DIV);
+}
+
+static void prepare_inputs(ml_node *node, struct script *script, size_t **buf_lens)
+{
+ for (size_t i = 0; i < script->len; i++)
+ {
+ struct op *op = &script->ops[i];
+
+ if (op->tag != IN)
+ {
+ continue;
+ }
+
+ /* the same input channel may have been encountered already */
+ bool have_prev = false;
+ for (size_t j = 0; j < i; j++)
+ {
+ struct op *prev = &script->ops[j];
+
+ if (prev->tag != IN)
+ {
+ continue;
+ }
+
+ if (op->src == prev->src)
+ {
+ op->index = prev->index;
+ have_prev = true;
+
+ if (op->offset+1 > (*buf_lens)[op->index])
+ {
+ /* grow lookback buffer length */
+ (*buf_lens)[op->index] = op->offset+1;
+ }
+
+ break;
+ }
+ }
+
+ if (have_prev)
+ {
+ continue;
+ }
+
+ op->index = ml_num_inputs(node);
+ ml_channel *dst = ml_add_input(node);
+ ml_connect(op->src,dst);
+
+ /* add buffer length */
+ *buf_lens = realloc(*buf_lens,sizeof(**buf_lens)*ml_num_inputs(node));
+ (*buf_lens)[op->index] = op->offset+1;
+ }
+}
+
+static size_t max_output_offset(struct script *script)
+{
+ size_t max = 0;
+
+ for (size_t i = 0; i < script->len; i++)
+ {
+ struct op *op = &script->ops[i];
+
+ if (op->tag != OUT)
+ {
+ continue;
+ }
+
+ if (op->offset+1 > max)
+ {
+ max = op->offset+1;
+ }
+ }
+
+ return max;
+}
+
+static void proc(const ml_proc_args *args);
+
+static int l_build(lua_State *s)
+{
+ struct script *script = luaL_checkudata(s,1,SCRIPT_TYPENAME);
+
+ /* copy script and add a halt instruction */
+ struct script *copy = calloc(1,script_alloc_size(script->len+1));
+ copy->len = script->len + 1;
+ memcpy(copy->ops,script->ops,script->len*sizeof(copy->ops[0]));
+ copy->ops[script->len].tag = HALT;
+
+ struct expr *expr = calloc(1,sizeof(*expr));
+ expr->script = copy;
+
+ /* add output channel */
+ ml_node *node = ml_create_node(proc,expr);
+ ml_label_node(node,"expression");
+ expr->out = ml_add_output(node);
+
+ /* set input ops indices and determine lookback buffer lengths */
+ prepare_inputs(node,copy,&expr->buf_lens);
+ expr->num_inputs = ml_num_inputs(node);
+
+ /* allocate lookback buffers for inputs */
+ expr->bufs = calloc(1,sizeof(*expr->bufs)*expr->num_inputs);
+ expr->cursors = calloc(1,sizeof(*expr->cursors)*expr->num_inputs);
+ for (size_t i = 0; i < expr->num_inputs; i++)
+ {
+ expr->bufs[i] = calloc(1,sizeof(*expr->bufs[i])*expr->buf_lens[i]);
+ }
+
+ /* allocate lookback buffer for output */
+ expr->out_buf_len = max_output_offset(copy);
+ expr->out_buf = calloc(1,sizeof(*expr->out_buf)*expr->out_buf_len);
+
+ *(void**) lua_newuserdata(s,sizeof(void*)) = expr;
+ luaL_setmetatable(s,EXPR_TYPENAME);
+
+ return 1;
+}
+
+static int l_out(lua_State *s)
+{
+ struct expr *e = *(void**) luaL_checkudata(s,1,EXPR_TYPENAME);
+ *(void**) lua_newuserdata(s,sizeof(void*)) = e->out;
+ luaL_setmetatable(s,MELISMA_OUTPUT_CHANNEL_TYPENAME);
+ return 1;
+}
+
+static void push(struct expr *expr, float v)
+{
+ if (expr->stack_p == expr->stack_len)
+ {
+ expr->stack_len = expr->stack_len == 0 ? 12 : 2*expr->stack_len;
+ expr->stack = realloc(expr->stack,sizeof(expr->stack[0])*expr->stack_len);
+ }
+
+ expr->stack[expr->stack_p++] = v;
+}
+
+static float pop(struct expr *expr)
+{
+ assert(expr->stack_p > 0);
+ return expr->stack[--expr->stack_p];
+}
+
+static void proc(const ml_proc_args *args)
+{
+ struct expr *e = args->userp;
+
+ static const void *tbl[] = {
+ [ADD] = &&op_add,
+ [SUB] = &&op_sub,
+ [MUL] = &&op_mul,
+ [DIV] = &&op_div,
+ [IN] = &&op_in,
+ [OUT] = &&op_out,
+ [CONST] = &&op_const,
+ [HALT] = &&op_halt,
+ };
+
+ float *out = args->out[0];
+
+ for (size_t i = 0; i < args->nframes; i++)
+ {
+ /* update lookback buffers */
+ for (size_t j = 0; j < e->num_inputs; j++)
+ {
+ e->bufs[j][e->cursors[j]] = args->in[j][i];
+ }
+
+ /* execute script */
+ size_t pc = 0;
+ struct op *op;
+ float rhs,lhs,res;
+ size_t l;
+
+ #define NEXT()\
+ op = &e->script->ops[pc++];\
+ goto *tbl[op->tag];
+
+ NEXT();
+
+ op_add:
+ rhs = pop(e);
+ lhs = pop(e);
+ push(e,lhs+rhs);
+ NEXT();
+
+ op_sub:
+ rhs = pop(e);
+ lhs = pop(e);
+ push(e,lhs-rhs);
+ NEXT();
+
+ op_mul:
+ rhs = pop(e);
+ lhs = pop(e);
+ push(e,lhs*rhs);
+ NEXT();
+
+ op_div:
+ rhs = pop(e);
+ lhs = pop(e);
+ push(e,lhs/rhs);
+ NEXT();
+
+ op_in:
+ l = e->cursors[op->index] + e->buf_lens[op->index] - op->offset;
+ while (l >= e->buf_lens[op->index])
+ {
+ l -= e->buf_lens[op->index];
+ }
+ push(e,e->bufs[op->index][l]);
+ NEXT();
+
+ op_out:
+ l = e->out_cursor + e->out_buf_len - op->offset;
+ while (l >= e->out_buf_len)
+ {
+ l -= e->out_buf_len;
+ }
+ push(e,e->out_buf[l]);
+ NEXT();
+
+ op_const:
+ push(e,op->value);
+ NEXT();
+
+ op_halt:
+ assert(e->stack_p == 1);
+ out[i] = pop(e);
+
+ /* update output lookback buffer */
+ if (e->out_buf_len > 0)
+ {
+ e->out_buf[e->out_cursor++] = out[i];
+ if (e->out_cursor == e->out_buf_len)
+ {
+ e->out_cursor = 0;
+ }
+ }
+
+ /* update input looback buffer cursors */
+ for (size_t j = 0; j < e->num_inputs; j++)
+ {
+ e->cursors[j]++;
+ if (e->cursors[j] == e->buf_lens[j])
+ {
+ e->cursors[j] = 0;
+ }
+ }
+ }
+}
+
+int luaopen_melisma_expression(lua_State *s)
+{
+/*** Build filters with arithmetic expressions.
+
+ local mexpression = require 'melisma.expression'
+ local input = mepxression.input
+
+ local lhs = ...
+ local rhs = ...
+
+ local script = input(lhs,0) + 0.5 * input(rhs,10)
+ local expression = script:build()
+
+@module expression
+*/
+ lua_newtable(s);
+ int tbl = lua_gettop(s);
+
+ lua_pushcfunction(s,l_const);
+ lua_setfield(s,tbl,"const");
+
+/*** Introduce an input signal in the expression.
+@static
+@function input
+@tparam channel sig Input signal.
+@tparam integer off Offset in no. of samples.
+@treturn script
+*/
+ lua_pushcfunction(s,l_input);
+ lua_setfield(s,tbl,"input");
+
+
+/*** Introduce the output signal in the expression.
+@static
+@function output
+@tparam integer off Offset in no. of samples.
+@treturn script
+*/
+ lua_pushcfunction(s,l_output);
+ lua_setfield(s,tbl,"output");
+
+/*** Represents an expression before compilation.
+Implements `__add`, `__sub`, `__mul` and `__div` for
+combining scripts in to larger ones.
+@type script
+*/
+/*** Compile an expression script.
+@function script:build
+@treturn expression
+*/
+ lua_pushcfunction(s,l_build);
+ lua_setfield(s,tbl,"build");
+
+ luaL_newmetatable(s,SCRIPT_TYPENAME);
+ int script_mt = lua_gettop(s);
+
+ lua_pushvalue(s,tbl);
+ lua_setfield(s,script_mt,"__index");
+
+ lua_pushcfunction(s,l_add);
+ lua_setfield(s,script_mt,"__add");
+
+ lua_pushcfunction(s,l_sub);
+ lua_setfield(s,script_mt,"__sub");
+
+ lua_pushcfunction(s,l_mul);
+ lua_setfield(s,script_mt,"__mul");
+
+ lua_pushcfunction(s,l_div);
+ lua_setfield(s,script_mt,"__div");
+
+ lua_newtable(s);
+ int expr_tbl = lua_gettop(s);
+
+ luaL_newmetatable(s,EXPR_TYPENAME);
+ int expr_mt = lua_gettop(s);
+
+ lua_pushvalue(s,expr_tbl);
+ lua_setfield(s,expr_mt,"__index");
+
+ lua_pushcfunction(s,l_out);
+ lua_setfield(s,expr_tbl,MUTIL_OUTPUT_CHANNEL_FUNCTION);
+
+ lua_settop(s,tbl);
+ return 1;
+}
diff --git a/mod/fs.c b/mod/fs.c
new file mode 100644
index 0000000..e08dfda
--- /dev/null
+++ b/mod/fs.c
@@ -0,0 +1,103 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <string.h>
+#include <errno.h>
+#include <dirent.h>
+#include <lauxlib.h>
+
+static const char *dirent_typestr(struct dirent *de)
+{
+ switch (de->d_type)
+ {
+ case DT_BLK: return "b";
+ case DT_CHR: return "c";
+ case DT_DIR: return "d";
+ case DT_FIFO: return "p";
+ case DT_REG: return "f";
+ case DT_LNK: return "l";
+ case DT_SOCK: return "s";
+ default: return "?";
+ }
+}
+
+static void push_dirent(lua_State *s, struct dirent *de)
+{
+ lua_newtable(s);
+ int tbl = lua_gettop(s);
+
+ lua_pushstring(s,de->d_name);
+ lua_setfield(s,tbl,"name");
+
+ lua_pushstring(s,dirent_typestr(de));
+ lua_setfield(s,tbl,"type");
+
+ lua_settop(s,tbl);
+}
+
+static int l_readdir(lua_State *s)
+{
+ const char *path = luaL_checkstring(s,1);
+ DIR *dirp = opendir(path);
+
+ if (dirp == NULL)
+ {
+ luaL_error(s,strerror(errno));
+ }
+
+ lua_newtable(s);
+ int tbl = lua_gettop(s);
+
+ int i = 1;
+ struct dirent *de;
+ while ((de = readdir(dirp)) != NULL)
+ {
+ push_dirent(s,de);
+ lua_rawseti(s,tbl,i++);
+ }
+
+ closedir(dirp);
+ lua_settop(s,tbl);
+ return 1;
+}
+
+int luaopen_melisma_fs(lua_State *s)
+{
+/*** File system utilities.
+@module fs
+*/
+ lua_newtable(s);
+ int tbl = lua_gettop(s);
+
+/*** List directory contents.
+@function readdir
+@tparam string path Directory path.
+@treturn table Directory contents.
+@usage
+local fs = require 'melisma.fs'
+local contents = fs.readdir('.')
+for _,entry in ipairs(contents) do
+ print(entry.name, entry.type)
+end
+*/
+ lua_pushcfunction(s,l_readdir);
+ lua_setfield(s,tbl,"readdir");
+
+ lua_settop(s,tbl);
+ return 1;
+}
diff --git a/mod/gain.c b/mod/gain.c
new file mode 100644
index 0000000..994efcf
--- /dev/null
+++ b/mod/gain.c
@@ -0,0 +1,164 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <string.h>
+#include <stdlib.h>
+#include <math.h>
+
+#include <lauxlib.h>
+
+#include "melisma.h"
+#include "mutil.h"
+
+#define GAIN_TYPENAME "gain"
+
+struct gain
+{
+ ml_node *node;
+ float value;
+ int mapped;
+ int db;
+};
+
+static void proc(const ml_proc_args *args)
+{
+ struct gain *gain = args->userp;
+
+ float *in = args->in[0];
+ float *out = args->out[0];
+ float *mapped = gain->mapped ? args->in[1] : NULL;
+ float v;
+
+ for (size_t i = 0; i < args->nframes; i++)
+ {
+ v = mapped ? mapped[i] : gain->value;
+
+ if (gain->db)
+ {
+ v = powf(10,v/20.f);
+ }
+
+ out[i] = v*in[i];
+ }
+}
+
+static int l_init(lua_State *s)
+{
+ int db_i = lua_upvalueindex(1);
+ int db = lua_tointeger(s,db_i);
+ ml_channel *audio_src, *mapped_src = NULL;
+ ml_channel *audio_dst, *mapped_dst;
+ float value = 0;
+
+
+ if (lua_isuserdata(s,1))
+ {
+ mapped_src = mutil_get_channel(s,1);
+ }
+ else
+ {
+ value = luaL_checknumber(s,1);
+ }
+
+ audio_src = mutil_get_channel(s,2);
+
+ struct gain *gain = calloc(1,sizeof(*gain));
+ gain->value = value;
+ gain->mapped = mapped_src != NULL;
+ gain->db = db;
+
+ ml_node *node = ml_create_node(proc,gain);
+ ml_label_node(node,"gain");
+ gain->node = node;
+
+ ml_add_output(node);
+
+ audio_dst = ml_add_input(node);
+ ml_connect(audio_src,audio_dst);
+
+ if (mapped_src)
+ {
+ mapped_dst = ml_add_input(node);
+ ml_connect(mapped_src,mapped_dst);
+ }
+
+ *(void**) lua_newuserdata(s,sizeof(void*)) = gain;
+ luaL_setmetatable(s,GAIN_TYPENAME);
+
+ return 1;
+}
+
+static int l_out(lua_State *s)
+{
+ struct gain *gain = *(void**) luaL_checkudata(s,1,GAIN_TYPENAME);
+ *(void**) lua_newuserdata(s,sizeof(void*)) = ml_get_output(gain->node,0);
+ luaL_setmetatable(s,MELISMA_OUTPUT_CHANNEL_TYPENAME);
+ return 1;
+}
+
+int luaopen_melisma_gain(lua_State *s)
+{
+ ml_check_version();
+
+/*** Apply gain to signals.
+ local mgain = require 'melisma.gain'
+
+ -- Interprets `cntl` as decibels and applies it to `input`
+ local input = ...
+ local cntl = ...
+ local gain = mgain.db(input,cntl)
+
+@classmod gain
+*/
+ lua_newtable(s);
+ int tbl = lua_gettop(s);
+
+/*** Creates a linear gain unit.
+@static
+@function linear
+@tparam channel input Input signal.
+@tparam number|channel gain Constant gain or a controlling signal.
+@treturn gain The newly created gain unit.
+*/
+ lua_pushinteger(s,0);
+ lua_pushcclosure(s,l_init,1);
+ lua_setfield(s,tbl,"linear");
+
+/*** Creates a decibel gain unit.
+@static
+@function db
+@tparam channel input Input signal.
+@tparam number|channel gain Constant gain in decibels or a controlling signal.
+@treturn gain The newly created gain unit.
+*/
+ lua_pushinteger(s,1);
+ lua_pushcclosure(s,l_init,1);
+ lua_setfield(s,tbl,"db");
+
+ lua_pushcfunction(s,l_out);
+ lua_setfield(s,tbl,MUTIL_OUTPUT_CHANNEL_FUNCTION);
+
+ luaL_newmetatable(s,GAIN_TYPENAME);
+ int mt = lua_gettop(s);
+
+ lua_pushvalue(s,tbl);
+ lua_setfield(s,mt,"__index");
+
+ lua_settop(s,tbl);
+ return 1;
+}
diff --git a/mod/interp.c b/mod/interp.c
new file mode 100644
index 0000000..c5bbefb
--- /dev/null
+++ b/mod/interp.c
@@ -0,0 +1,257 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <stdlib.h>
+#include <string.h>
+#include <lauxlib.h>
+#include "melisma.h"
+#include "mutil.h"
+
+#define INTERP_TYPENAME "interp"
+#define DEFAULT_METHOD "linear"
+
+struct interp;
+typedef float (*smp_fn)(float);
+
+enum {
+ LINEAR,
+ EASEIN,
+ EASEOUT,
+ SMOOTHSTEP,
+};
+
+struct interp
+{
+ ml_channel *out;
+ smp_fn smp;
+ float src;
+ float dst;
+ size_t t0;
+ size_t t;
+ int method;
+};
+
+static float sample(struct interp *ip)
+{
+ float x = ip->t0 == 0 ? 0 : (float)ip->t / (float)ip->t0;
+ switch (ip->method)
+ {
+ case LINEAR:
+ break;
+ case EASEIN:
+ x = x*x;
+ break;
+ case EASEOUT:
+ x = 1 -x;
+ x = 1 - x*x;
+ break;
+ case SMOOTHSTEP:
+ x = x*x*(3 - 2*x);
+ break;
+ }
+ return ip->src + x*(ip->dst - ip->src);
+}
+
+static void proc(const ml_proc_args *args)
+{
+ struct interp *ip = args->userp;
+
+ float *out = args->out[0];
+
+ for (size_t i = 0; i < args->nframes; i++)
+ {
+ out[i] = sample(ip);
+
+ if (ip->t == ip->t0)
+ {
+ ip->t = 0;
+ ip->t0 = 0;
+ ip->src = ip->dst;
+ }
+ else
+ {
+ ip->t++;
+ }
+ }
+}
+
+static int l_init(lua_State *s)
+{
+ float v = luaL_checknumber(s,1);
+
+ struct interp *ip = calloc(1,sizeof(*ip));
+ ip->src = v;
+ ip->dst = v;
+ ip->method = LINEAR;
+
+ ml_node *node = ml_create_node(proc,ip);
+ ml_label_node(node,"interp");
+ ip->out = ml_add_output(node);
+
+ *(void**) lua_newuserdata(s,sizeof(void*)) = ip;
+ luaL_setmetatable(s,INTERP_TYPENAME);
+
+ return 1;
+}
+
+static int l_set(lua_State *s)
+{
+ struct interp *ip = *(void**) luaL_checkudata(s,1,INTERP_TYPENAME);
+ float v = luaL_checknumber(s,2);
+
+ ip->src = v;
+ ip->dst = v;
+ ip->t0 = 0;
+ ip->t = 0;
+
+ return 0;
+}
+
+static int l_target(lua_State *s)
+{
+ int method_i = lua_upvalueindex(1);
+ int method = luaL_checkinteger(s,method_i);
+ struct interp *ip = *(void**) luaL_checkudata(s,1,INTERP_TYPENAME);
+ float v = luaL_checknumber(s,2);
+ float t = luaL_checknumber(s,3);
+
+ size_t sr = ml_sample_rate();
+
+ ip->src = sample(ip);
+ ip->dst = v;
+ ip->t0 = sr*t;
+ ip->t = 0;
+ ip->method = method;
+
+ return 0;
+}
+
+static int l_sample(lua_State *s)
+{
+ struct interp *ip = *(void**) luaL_checkudata(s,1,INTERP_TYPENAME);
+ lua_pushnumber(s,sample(ip));
+ return 1;
+}
+
+static int l_out(lua_State *s)
+{
+ struct interp *ip = *(void**) luaL_checkudata(s,1,INTERP_TYPENAME);
+ *(void**) lua_newuserdata(s,sizeof(void*)) = ip->out;
+ luaL_setmetatable(s,MELISMA_OUTPUT_CHANNEL_TYPENAME);
+ return 1;
+}
+
+int luaopen_melisma_interp(lua_State *s)
+{
+ ml_check_version();
+
+/*** Interpolation.
+
+ local minterp = require 'melisma.interp'
+ local mevent = require 'melisma.event'
+ local mgain = require 'melisma.gain'
+
+ local interp = minterp.init(0)
+
+ interp:linear(1,1)
+
+ mevent.schedule(0.5, function()
+ print('current value:', interp:sample())
+ end)
+
+ local sig = ...
+ local gain = mgain.linear(sig,interp)
+
+@classmod interp
+*/
+ lua_newtable(s);
+ int tbl = lua_gettop(s);
+
+/*** Initialize an interpolation unit.
+@static
+@function init
+@tparam number x0 Initial value.
+@treturn interp The newly created interpolation unit.
+*/
+ lua_pushcfunction(s,l_init);
+ lua_setfield(s,tbl,"init");
+
+/*** Set value directly.
+@function set
+@tparam number x New value.
+*/
+ lua_pushcfunction(s,l_set);
+ lua_setfield(s,tbl,"set");
+
+/*** Interpolate using linear interpolation.
+@function linear
+@tparam number v Target value.
+@tparam number t Interpolation time.
+*/
+ lua_pushnumber(s,LINEAR);
+ lua_pushcclosure(s,l_target,1);
+ lua_setfield(s,tbl,"linear");
+
+
+/*** Interpolate using ease-in interpolation.
+@function ease_in
+@tparam number v Target value.
+@tparam number t Interpolation time.
+*/
+ lua_pushnumber(s,EASEIN);
+ lua_pushcclosure(s,l_target,1);
+ lua_setfield(s,tbl,"ease_in");
+
+
+/*** Interpolate using ease-out interpolation.
+@function ease_out
+@tparam number v Target value.
+@tparam number t Interpolation time.
+*/
+ lua_pushnumber(s,EASEOUT);
+ lua_pushcclosure(s,l_target,1);
+ lua_setfield(s,tbl,"ease_out");
+
+/*** Interpolate using smoothstep interpolation.
+@function smoothstep
+@tparam number v Target value.
+@tparam number t Interpolation time.
+*/
+ lua_pushnumber(s,SMOOTHSTEP);
+ lua_pushcclosure(s,l_target,1);
+ lua_setfield(s,tbl,"smoothstep");
+
+/*** Get the current value.
+@function sample
+@treturn number The current value.
+*/
+ lua_pushcfunction(s,l_sample);
+ lua_setfield(s,tbl,"sample");
+
+ lua_pushcfunction(s,l_out);
+ lua_setfield(s,tbl,MUTIL_OUTPUT_CHANNEL_FUNCTION);
+
+ luaL_newmetatable(s,INTERP_TYPENAME);
+ int mt = lua_gettop(s);
+
+ lua_pushvalue(s,tbl);
+ lua_setfield(s,mt,"__index");
+
+ lua_settop(s,tbl);
+ return 1;
+}
diff --git a/mod/keycallback.c b/mod/keycallback.c
new file mode 100644
index 0000000..2352cbf
--- /dev/null
+++ b/mod/keycallback.c
@@ -0,0 +1,147 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <stdlib.h>
+#include <stdbool.h>
+#include <string.h>
+#include <termios.h>
+#include <unistd.h>
+#include <lauxlib.h>
+#include "melisma.h"
+#include "melisma/ring.h"
+
+static int fn = LUA_NOREF;
+static struct termios tty = {0};
+static struct termios oldtty = {0};
+static lua_State *state;
+static ml_ring *ring = NULL;
+
+static void make_arg(lua_State *s, size_t len, const void *data)
+{
+ lua_pushlstring(s,data,len);
+}
+
+static void io_handler(int fd, int flags, void *args)
+{
+ char c;
+
+ if (read(fd,&c,sizeof c) <= 0)
+ {
+ return;
+ }
+
+ ml_ring_put_blocking(ring,*(uint8_t*) &c);
+}
+
+static void cleanup(void)
+{
+ tcsetattr(STDIN_FILENO,TCSANOW,&oldtty);
+}
+
+static void proc(const ml_proc_args *args)
+{
+ if (fn == LUA_NOREF)
+ {
+ return;
+ }
+
+ char buf[1];
+ while (ml_ring_read(ring,buf,sizeof buf) == sizeof buf)
+ {
+ lua_pushlstring(state,buf,sizeof buf);
+ int arg = luaL_ref(state,LUA_REGISTRYINDEX);
+ ml_schedule_event(0,fn,arg);
+ }
+}
+
+static void setup(lua_State *s)
+{
+ static bool init = false;
+
+ if (init)
+ {
+ return;
+ }
+
+ init = true;
+
+ tcgetattr(STDIN_FILENO,&oldtty);
+ tty = oldtty;
+ tty.c_lflag &= ~ECHO;
+ tty.c_lflag &= ~ICANON;
+ tcsetattr(STDIN_FILENO,TCSANOW,&tty);
+ tcflush(STDIN_FILENO,TCIFLUSH);
+
+ ml_add_io_handler(STDIN_FILENO,POLLIN,io_handler,NULL);
+ ring = ml_ring_init(4096);
+ state = s;
+
+ ml_node *node = ml_create_node(proc,NULL);
+ ml_channel *src = ml_add_output(node);
+ ml_channel *dst = ml_add_input(ml_get_root_node());
+ ml_connect(src,dst);
+ ml_label_node(node,"keycallback");
+
+ atexit(cleanup);
+}
+
+static int l_set(lua_State *s)
+{
+ if (fn != LUA_NOREF)
+ {
+ luaL_unref(s,LUA_REGISTRYINDEX,fn);
+ fn = LUA_NOREF;
+ }
+
+ if (lua_isnil(s,1))
+ {
+ return 0;
+ }
+
+ luaL_checktype(s,1,LUA_TFUNCTION);
+ lua_pushvalue(s,1);
+ fn = luaL_ref(s,LUA_REGISTRYINDEX);
+
+ setup(s);
+
+ return 0;
+}
+
+int luaopen_melisma_keycallback(lua_State *s)
+{
+/*** Set callback for keyboard input.
+ local mkeycallback = require 'melisma.keycallback'
+
+ mkeycallback.set(function(c)
+ print('you pressed', c)
+ end)
+@module keycallback
+*/
+ lua_newtable(s);
+ int tbl = lua_absindex(s,lua_gettop(s));
+
+/*** Set the callback.
+@function set
+@tparam function fn Callback function.
+*/
+ lua_pushcfunction(s,l_set);
+ lua_setfield(s,tbl,"set");
+
+ lua_settop(s,tbl);
+ return 1;
+}
diff --git a/mod/ladspa.c b/mod/ladspa.c
new file mode 100644
index 0000000..4b681b9
--- /dev/null
+++ b/mod/ladspa.c
@@ -0,0 +1,604 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <stdlib.h>
+#include <string.h>
+#include <stdbool.h>
+#include <dlfcn.h>
+#include <sys/stat.h>
+#include <lauxlib.h>
+#include "melisma.h"
+#include "mutil.h"
+#include "../external/ladspa.h"
+
+#define LADSPA_TYPENAME "ladspa plugin"
+
+struct cache
+{
+ const char *key;
+ void *ptr;
+ struct cache *next;
+};
+
+struct ladspa
+{
+ ml_node *node;
+ const LADSPA_Descriptor *dsc;
+ LADSPA_Handle *hdl;
+ float *cntl;
+};
+
+static struct cache *lib_cache = NULL;
+static struct cache *dsc_cache = NULL;
+
+static void *lookup(struct cache *c, const char *k)
+{
+ while (c)
+ {
+ if (strcmp(c->key,k) == 0)
+ {
+ return c->ptr;
+ }
+ c = c->next;
+ }
+ return NULL;
+}
+
+static void cache(struct cache **c, const char *k, void *p)
+{
+ struct cache *head = calloc(1,sizeof(*head));
+ head->key = strdup(k);
+ head->ptr = p;
+ head->next = *c;
+ *c = head;
+}
+
+static int exists(const char *path)
+{
+ struct stat st;
+ return stat(path,&st) == 0;
+}
+
+static const char *find_lib(const char *path)
+{
+ if (exists(path))
+ {
+ return path;
+ }
+
+ if (strchr(path,'/') != NULL)
+ {
+ return NULL;
+ }
+
+ char *lp = getenv("LADSPA_PATH");
+
+ if (lp == NULL)
+ {
+ return NULL;
+ }
+
+ lp = strdup(lp);
+ char *ptr = lp;
+ char *dir;
+
+ while ((dir = strtok(ptr,":")) != NULL)
+ {
+ ptr = NULL;
+
+ static char buf[PATH_MAX];
+ snprintf(buf,sizeof buf,"%s/%s",dir,path);
+
+ if (exists(buf))
+ {
+ free(lp);
+ return buf;
+ }
+ }
+
+ free(lp);
+
+ return NULL;
+}
+
+static void *get_lib(lua_State *s, const char *path)
+{
+ void *lib;
+
+ if ((lib = lookup(lib_cache,path)) != NULL)
+ {
+ return lib;
+ }
+
+ const char *found = find_lib(path);
+
+ if (found == NULL)
+ {
+ luaL_error(s,"failed to find library %s",path);
+ }
+
+ lib = dlopen(found,RTLD_NOW);
+
+ if (lib == NULL)
+ {
+ luaL_error(s,"failed to load library %s: %s",dlerror());
+ }
+
+ cache(&lib_cache,path,lib);
+
+ return lib;
+}
+
+static void parse_spec(lua_State *s, const char *spec, char *path, char *name)
+{
+ char buf[128];
+ strcpy(buf,spec);
+
+ const char *f;
+
+ if ((f = strtok(buf,":")) == NULL)
+ {
+ goto invalid;
+ }
+
+ strcpy(path,f);
+
+ if ((f = strtok(NULL,":")) == NULL)
+ {
+ goto invalid;
+ }
+
+ strcpy(name,f);
+
+ return;
+
+invalid:
+ luaL_error(s,"invalid ladspa spec: %s",spec);
+}
+
+static const LADSPA_Descriptor *get_descriptor(lua_State *s, const char *spec)
+{
+ const LADSPA_Descriptor *dsc;
+ LADSPA_Descriptor_Function ldf;
+ char path[128];
+ char name[128];
+ void *lib;
+ int i = 0;
+
+ parse_spec(s,spec,path,name);
+
+ if ((dsc = lookup(dsc_cache,name)) != NULL)
+ {
+ return dsc;
+ }
+
+ lib = get_lib(s,path);
+ ldf = dlsym(lib,"ladspa_descriptor");
+
+ if (ldf == NULL)
+ {
+ luaL_error(s,"failed to load ladspa_descriptor from %s: %s",path,dlerror());
+ }
+
+ while ((dsc = ldf(i++)) != NULL)
+ {
+ if (strcmp(dsc->Name,name) == 0)
+ {
+ break;
+ }
+
+ if (strcmp(dsc->Label,name) == 0)
+ {
+ break;
+ }
+ }
+
+ if (dsc == NULL)
+ {
+ luaL_error(s,"ladspa plugin library %s does not define plugin %s",path,name);
+ }
+
+ cache(&dsc_cache,spec,(void*)dsc);
+
+ return dsc;
+}
+
+static void instantiate(struct ladspa *l, const ml_proc_args *args)
+{
+ unsigned long sr = ml_sample_rate();
+
+ l->hdl = l->dsc->instantiate(l->dsc,sr);
+
+ if (l->dsc->activate)
+ {
+ l->dsc->activate(l->hdl);
+ }
+
+ float *ptr;
+ size_t ii = 0, oi = 0, ci = 0;
+
+ for (size_t i = 0; i < l->dsc->PortCount; i++)
+ {
+ LADSPA_PortDescriptor pd = l->dsc->PortDescriptors[i];
+
+ if (LADSPA_IS_PORT_CONTROL(pd) && LADSPA_IS_PORT_INPUT(pd))
+ {
+ l->dsc->connect_port(l->hdl,i,&l->cntl[ci++]);
+ continue;
+ }
+
+ if (LADSPA_IS_PORT_AUDIO(pd) && LADSPA_IS_PORT_INPUT(pd))
+ {
+ ptr = args->in[ii++];
+ l->dsc->connect_port(l->hdl,i,ptr);
+ continue;
+ }
+
+ if (LADSPA_IS_PORT_AUDIO(pd) && LADSPA_IS_PORT_OUTPUT(pd))
+ {
+ ptr = args->out[oi++];
+ l->dsc->connect_port(l->hdl,i,ptr);
+ continue;
+ }
+ }
+}
+
+static void proc(const ml_proc_args *args)
+{
+ struct ladspa *l = args->userp;
+
+ if (l->hdl == NULL)
+ {
+ instantiate(l,args);
+ }
+
+ l->dsc->run(l->hdl,args->nframes);
+}
+
+static int l_load(lua_State *s)
+{
+ const char *spec = luaL_checkstring(s,1);
+ const LADSPA_Descriptor *dsc = get_descriptor(s,spec);
+
+ struct ladspa *l = calloc(1,sizeof(*l));
+ l->dsc = dsc;
+ l->node = ml_create_node(proc,l);
+ ml_label_node(l->node,"LADSPA (%s)",spec);
+
+ size_t num_cntl = 0, arg = 2;
+ for (size_t i = 0; i < dsc->PortCount; i++)
+ {
+ LADSPA_PortDescriptor pd = dsc->PortDescriptors[i];
+
+ if (LADSPA_IS_PORT_CONTROL(pd) && LADSPA_IS_PORT_INPUT(pd))
+ {
+ num_cntl++;
+ }
+
+ if (LADSPA_IS_PORT_AUDIO(pd) && LADSPA_IS_PORT_INPUT(pd))
+ {
+ ml_channel *src = mutil_get_channel(s,arg++);
+ ml_channel *dst = ml_add_input(l->node);
+ ml_connect(src,dst);
+ }
+
+ if (LADSPA_IS_PORT_AUDIO(pd) && LADSPA_IS_PORT_OUTPUT(pd))
+ {
+ ml_add_output(l->node);
+ }
+ }
+
+ l->cntl = calloc(1,sizeof(*l->cntl)*num_cntl);
+
+ *(void**) lua_newuserdata(s,sizeof(void*)) = l;
+ luaL_setmetatable(s,LADSPA_TYPENAME);
+
+ return 1;
+}
+
+static int l_control(lua_State *s)
+{
+ struct ladspa *l = *(void**) luaL_checkudata(s,1,LADSPA_TYPENAME);
+ int i = luaL_checkinteger(s,2);
+ float v = luaL_checknumber(s,3);
+
+ if (i < 1 || i > l->dsc->PortCount)
+ {
+ luaL_error(s,"control port index out of bounds: %d",i);
+ }
+
+ if (!LADSPA_IS_PORT_CONTROL(l->dsc->PortDescriptors[i-1]))
+ {
+ luaL_error(s,"port %d is not a control port",i);
+ }
+
+ if (!LADSPA_IS_PORT_INPUT(l->dsc->PortDescriptors[i-1]))
+ {
+ luaL_error(s,"port %d is not an input port",i);
+ }
+
+ l->cntl[i-1] = v;
+ return 0;
+}
+
+static int l_info(lua_State *s)
+{
+ const char *spec = luaL_checkstring(s,1);
+ const LADSPA_Descriptor *dsc = get_descriptor(s,spec);
+
+ lua_newtable(s);
+ int info = lua_gettop(s);
+
+ lua_pushstring(s,dsc->Name);
+ lua_setfield(s,info,"name");
+
+ lua_pushstring(s,dsc->Label);
+ lua_setfield(s,info,"label");
+
+ lua_pushstring(s,dsc->Maker);
+ lua_setfield(s,info,"maker");
+
+ lua_pushstring(s,dsc->Copyright);
+ lua_setfield(s,info,"copyright");
+
+ lua_pushinteger(s,dsc->UniqueID);
+ lua_setfield(s,info,"id");
+
+ lua_newtable(s);
+ int ports = lua_gettop(s);
+
+ for (int i = 0; i < dsc->PortCount; i++)
+ {
+ const char *name = dsc->PortNames[i];
+ const LADSPA_PortDescriptor pd = dsc->PortDescriptors[i];
+ const LADSPA_PortRangeHint *prh = &dsc->PortRangeHints[i];
+ const LADSPA_PortRangeHintDescriptor prhd = prh->HintDescriptor;
+
+ const char *dir = LADSPA_IS_PORT_INPUT(pd) ? "input" : "output";
+ const char *type = LADSPA_IS_PORT_AUDIO(pd) ? "audio" : "control";
+ float lower = prh->LowerBound;
+ float upper = prh->UpperBound;
+ bool bounded_below = LADSPA_IS_HINT_BOUNDED_BELOW(prhd);
+ bool bounded_above = LADSPA_IS_HINT_BOUNDED_ABOVE(prhd);
+ bool toggled = LADSPA_IS_HINT_TOGGLED(prhd);
+ bool sample_rate = LADSPA_IS_HINT_SAMPLE_RATE(prhd);
+ bool logarithmic = LADSPA_IS_HINT_LOGARITHMIC(prhd);
+ bool integer = LADSPA_IS_HINT_INTEGER(prhd);
+ bool has_default = LADSPA_IS_HINT_HAS_DEFAULT(prhd);
+ bool default_minimum = LADSPA_IS_HINT_DEFAULT_MINIMUM(prhd);
+ bool default_low = LADSPA_IS_HINT_DEFAULT_LOW(prhd);
+ bool default_middle = LADSPA_IS_HINT_DEFAULT_MIDDLE(prhd);
+ bool default_high = LADSPA_IS_HINT_DEFAULT_HIGH(prhd);
+ bool default_maximum = LADSPA_IS_HINT_DEFAULT_MAXIMUM(prhd);
+ bool default_0 = LADSPA_IS_HINT_DEFAULT_0(prhd);
+ bool default_1 = LADSPA_IS_HINT_DEFAULT_1(prhd);
+ bool default_100 = LADSPA_IS_HINT_DEFAULT_100(prhd);
+ bool default_440 = LADSPA_IS_HINT_DEFAULT_440(prhd);
+
+ lua_newtable(s);
+ int port = lua_gettop(s);
+
+ lua_pushstring(s,name);
+ lua_setfield(s,port,"name");
+
+ lua_pushstring(s,dir);
+ lua_setfield(s,port,"direction");
+
+ lua_pushstring(s,type);
+ lua_setfield(s,port,"type");
+
+ lua_newtable(s);
+ int hints = lua_gettop(s);
+
+ // if (bounded_below) { lua_pushboolean(s,true); lua_setfield(s,hints,"bounded_below"); }
+ // if (bounded_above) { lua_pushboolean(s,true); lua_setfield(s,hints,"bounded_above"); }
+ if (toggled) { lua_pushboolean(s,true); lua_setfield(s,hints,"toggled"); }
+ if (sample_rate) { lua_pushboolean(s,true); lua_setfield(s,hints,"sample_rate"); }
+ if (logarithmic) { lua_pushboolean(s,true); lua_setfield(s,hints,"logarithmic"); }
+ if (integer) { lua_pushboolean(s,true); lua_setfield(s,hints,"integer"); }
+ if (bounded_below) { lua_pushnumber(s,lower); lua_setfield(s,hints,"lower_bound"); }
+ if (bounded_above) { lua_pushnumber(s,upper); lua_setfield(s,hints,"upper_bound"); }
+
+ if (has_default)
+ {
+ float def = 0;
+
+ if (default_minimum) def = lower;
+ else if (default_low) def = 0.25*lower + 0.75*upper;
+ else if (default_middle) def = 0.5*lower + 0.5*upper;
+ else if (default_high) def = 0.75*lower + 0.25*upper;
+ else if (default_maximum) def = upper;
+ else if (default_0) def = 0;
+ else if (default_1) def = 1;
+ else if (default_100) def = 100;
+ else if (default_440) def = 440;
+
+ lua_pushnumber(s,def);
+ lua_setfield(s,hints,"default");
+ }
+
+ lua_pushvalue(s,hints);
+ lua_setfield(s,port,"hints");
+
+ lua_pushvalue(s,port);
+ lua_rawseti(s,ports,i + 1);
+ lua_settop(s,ports);
+ }
+
+ lua_pushvalue(s,ports);
+ lua_setfield(s,info,"ports");
+
+ lua_settop(s,info);
+
+ return 1;
+}
+
+static int l_lib_info(lua_State *s)
+{
+ const char *path;
+ void *lib;
+ LADSPA_Descriptor_Function ldf;
+ const LADSPA_Descriptor *dsc;
+
+ path = luaL_checkstring(s,1);
+ lib= get_lib(s,path);
+ ldf = dlsym(lib,"ladspa_descriptor");
+
+ if (ldf == NULL)
+ {
+ luaL_error(s,"failed to load ladspa_descriptor from %s: %s",path,dlerror());
+ }
+
+ lua_newtable(s);
+ int info = lua_gettop(s);
+
+ int i = 0;
+ while ((dsc = ldf(i++)) != NULL)
+ {
+ lua_newtable(s);
+ int plugin = lua_gettop(s);
+
+ lua_pushstring(s,dsc->Name);
+ lua_setfield(s,plugin,"name");
+
+ lua_pushstring(s,dsc->Label);
+ lua_setfield(s,plugin,"label");
+
+ lua_pushstring(s,dsc->Maker);
+ lua_setfield(s,plugin,"maker");
+
+ lua_pushvalue(s,plugin);
+ lua_rawseti(s,info,i);
+ lua_settop(s,info);
+ }
+
+ lua_settop(s,info);
+ return 1;
+}
+
+static int l_out(lua_State *s)
+{
+ struct ladspa *l = *(void**) luaL_checkudata(s,1,LADSPA_TYPENAME);
+ size_t i = luaL_optinteger(s,2,1);
+ *(void**) lua_newuserdata(s,sizeof(void*)) = ml_get_output(l->node,i - 1);
+ luaL_setmetatable(s,MELISMA_OUTPUT_CHANNEL_TYPENAME);
+ return 1;
+}
+
+int luaopen_melisma_ladspa(lua_State *s)
+{
+/*** LADSPA plugin support.
+
+ local mladspa = require 'melisma.ladspa'
+
+ local spec = 'caps.so:Plate'
+ local info = mladspa.info(spec)
+
+ print('name:',info.name)
+ print('maker:',info.maker)
+
+ local input = ...
+ local plate = mladspa.load(spec,input)
+
+ plate:control(1,1)
+
+ local left = plate:out(6)
+ local right = plate:out(7)
+
+@classmod ladspa
+*/
+ lua_newtable(s);
+ int tbl = lua_gettop(s);
+
+/*** Load a ladspa plugin.
+
+Input signals are connected to the plugins audio input ports in the
+order that they are declared.
+
+@static
+@function load
+@tparam string spec Plugin specification.
+@tparam channel ... Input signals.
+*/
+ lua_pushcfunction(s,l_load);
+ lua_setfield(s,tbl,"load");
+
+/*** Get information about a specific plugin.
+
+Plugin specifications are written in the format `<path>:<plugin>`,
+where `<path>` is the full name of the plugin library
+(e.g. `caps.so`), and `<plugin>` is the name or label of the plugin.
+If `<path>` is not an absolute path, the library is searched for
+first in the current directory, then in `LADSPA_PATH`.
+
+This can be used to generate more ergonomic plugin interfaces.
+
+@static
+@function info
+@tparam string spec Plugin specification.
+@treturn table Plugin information.
+*/
+ lua_pushcfunction(s,l_info);
+ lua_setfield(s,tbl,"info");
+
+/*** List plugins in the specified plugin library.
+
+If `path` is not an absolute path, the library is searched for
+first in the current directory, then in `LADSPA_PATH`.
+
+@static
+@function lib_info
+@tparam string path Path to plugin library.
+@treturn table List of short plugin descriptions.
+*/
+ lua_pushcfunction(s,l_lib_info);
+ lua_setfield(s,tbl,"lib_info");
+
+/*** Set the value of an input control port.
+
+Ports are indexed based on the order they are declared by the plugin.
+This also means the indices of control ports are not necessarily consecutive.
+
+@function control
+@tparam integer i One-based port index.
+@tparam number v New control port value.
+*/
+ lua_pushcfunction(s,l_control);
+ lua_setfield(s,tbl,"control");
+
+/*** Get the audio output port at the specified index.
+
+Ports are indexed based on the order they are declared by the plugin.
+This also means the indices of audio ports are not necessarily consecutive.
+
+@function out
+@tparam integer i One-based port index.
+@treturn channel Audio output at specified index.
+*/
+ lua_pushcfunction(s,l_out);
+ lua_setfield(s,tbl,"out");
+
+ luaL_newmetatable(s,LADSPA_TYPENAME);
+ int mt = lua_gettop(s);
+
+ lua_pushvalue(s,tbl);
+ lua_setfield(s,mt,"__index");
+
+ lua_settop(s,tbl);
+ return 1;
+}
diff --git a/mod/lookback.c b/mod/lookback.c
new file mode 100644
index 0000000..115f823
--- /dev/null
+++ b/mod/lookback.c
@@ -0,0 +1,106 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <stdlib.h>
+
+#include <lauxlib.h>
+
+#include "melisma.h"
+#include "mutil.h"
+
+#define LOOKBACK_TYPENAME "lookback"
+
+struct lookback
+{
+ ml_node *node;
+ size_t cursor;
+ size_t len;
+ float buf[];
+};
+
+static void proc(const ml_proc_args *args)
+{
+ struct lookback *lb = args->userp;
+
+ for (size_t i = 0; i < args->nframes; i++)
+ {
+ args->out[0][i] = lb->buf[lb->cursor];
+ lb->buf[lb->cursor] = args->in[0][i];
+
+ lb->cursor++;
+
+ if (lb->cursor == lb->len)
+ {
+ lb->cursor = 0;
+ }
+ }
+}
+
+static int l_init(lua_State *s)
+{
+ float t = luaL_checknumber(s,1);
+ ml_channel *src = mutil_get_channel(s,2);
+
+ unsigned long sr = ml_sample_rate();
+ unsigned long len = sr*t;
+
+ struct lookback *lb = calloc(1,sizeof(*lb) + len*sizeof(lb->buf[0]));
+ lb->len = len;
+
+ ml_node *node = ml_create_node(proc,lb);
+ ml_label_node(node,"lookback");
+ lb->node = node;
+
+ ml_channel *dst = ml_add_input(node);
+ ml_connect(src,dst);
+ ml_add_output(node);
+
+ *(void**) lua_newuserdata(s,sizeof(void*)) = lb;
+ luaL_setmetatable(s,LOOKBACK_TYPENAME);
+
+ return 1;
+}
+
+static int l_out(lua_State *s)
+{
+ struct lookback *lb = *(void**) luaL_checkudata(s,1,LOOKBACK_TYPENAME);
+ *(void**) lua_newuserdata(s,sizeof(void*)) = ml_get_output(lb->node,0);
+ luaL_setmetatable(s,MELISMA_OUTPUT_CHANNEL_TYPENAME);
+ return 1;
+}
+
+int luaopen_melisma_lookback(lua_State *s)
+{
+ lua_newtable(s);
+ int tbl = lua_gettop(s);
+
+ lua_pushcfunction(s,l_init);
+ lua_setfield(s,tbl,"init");
+
+ lua_pushcfunction(s,l_out);
+ lua_setfield(s,tbl,MUTIL_OUTPUT_CHANNEL_FUNCTION);
+
+ luaL_newmetatable(s,LOOKBACK_TYPENAME);
+ int mt = lua_gettop(s);
+
+ lua_pushvalue(s,tbl);
+ lua_setfield(s,mt,"__index");
+
+ lua_settop(s,tbl);
+ return 1;
+}
diff --git a/mod/map.h b/mod/map.h
new file mode 100644
index 0000000..c56487e
--- /dev/null
+++ b/mod/map.h
@@ -0,0 +1,49 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#ifndef MAP_H
+#define MAP_H
+
+#include "melisma.h"
+#include <lauxlib.h>
+
+struct map
+{
+ float value;
+ int channel;
+};
+
+static void map_from_arg(lua_State *s, struct map *m, int arg)
+{
+ if (lua_isnumber(s,arg))
+ {
+ }
+
+ if (lua_isuserdata(s,arg))
+ {
+ }
+
+ luaL_argerror(s,arg,"expected number or output channel");
+}
+
+static float map_get(struct map *m, size_t i, const ml_proc_args *args)
+{
+ return m->channel >= 0 ? args->in[m->channel][i] : m->value;
+}
+
+#endif
diff --git a/mod/mixer.c b/mod/mixer.c
new file mode 100644
index 0000000..ab5ade2
--- /dev/null
+++ b/mod/mixer.c
@@ -0,0 +1,114 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <string.h>
+#include <lauxlib.h>
+#include "melisma.h"
+#include "mutil.h"
+
+#define MIXER_TYPENAME "mixer"
+
+static void proc(const ml_proc_args *args)
+{
+ float *out = args->out[0];
+
+ memset(out,0,args->nframes * sizeof(out[0]));
+
+ for (size_t i = 0; i < args->icnt; i++)
+ {
+ const float *in = args->in[i];
+
+ for (size_t j = 0; j < args->nframes; j++)
+ {
+ out[j] += in[j];
+ }
+ }
+}
+
+static int l_init(lua_State *s)
+{
+ int n = lua_gettop(s);
+
+ if (n == 0)
+ {
+ luaL_error(s,"need at least one input channel");
+ }
+
+ ml_node *node = ml_create_node(proc,NULL);
+ ml_label_node(node,"mix");
+
+ for (int i = 0; i < n; i++)
+ {
+ ml_channel *src = mutil_get_channel(s,i+1);
+ ml_channel *dst = ml_add_input(node);
+ ml_connect(src,dst);
+ }
+
+ ml_channel *out = ml_add_output(node);
+
+ *(void**) lua_newuserdata(s,sizeof(void*)) = out;
+ luaL_setmetatable(s,MIXER_TYPENAME);
+
+ return 1;
+}
+
+static int l_out(lua_State *s)
+{
+ ml_channel *c = *(void**) luaL_checkudata(s,1,MIXER_TYPENAME);
+ *(void**) lua_newuserdata(s,sizeof(void*)) = c;
+ luaL_setmetatable(s,MELISMA_OUTPUT_CHANNEL_TYPENAME);
+ return 1;
+}
+
+int luaopen_melisma_mixer(lua_State *s)
+{
+ ml_check_version();
+
+/*** Mix signals.
+
+ local mmixer = require 'melisma.mixer'
+
+ local signals = ...
+ local mixer = mmixer.init(table.unpack(signals))
+
+@classmod mixer
+*/
+ lua_newtable(s);
+ int tbl = lua_gettop(s);
+
+/*** Mix input signals in to a single output signal.
+@static
+@function init
+@tparam channel ... Input signals.
+@treturn mixer The newly created mixer unit.
+*/
+ lua_pushcfunction(s,l_init);
+ lua_setfield(s,tbl,"init");
+
+ lua_pushcfunction(s,l_out);
+ lua_setfield(s,tbl,MUTIL_OUTPUT_CHANNEL_FUNCTION);
+
+ luaL_newmetatable(s,MIXER_TYPENAME);
+ int mt = lua_gettop(s);
+
+ lua_pushvalue(s,tbl);
+ lua_setfield(s,mt,"__index");
+
+ lua_settop(s,tbl);
+ return 1;
+}
diff --git a/mod/mutil.h b/mod/mutil.h
new file mode 100644
index 0000000..215508b
--- /dev/null
+++ b/mod/mutil.h
@@ -0,0 +1,69 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#ifndef MUTIL_H
+#define MUTIL_H
+
+/* TODO: Move to main repo? */
+
+#include "melisma.h"
+#include <lauxlib.h>
+
+#define MUTIL_OUTPUT_CHANNEL_FUNCTION "__ml_out"
+
+static ml_channel *mutil_get_optchannel(lua_State *s, int i)
+{
+ ml_channel **c;
+
+ if ((c = luaL_testudata(s,i,MELISMA_OUTPUT_CHANNEL_TYPENAME)) != NULL)
+ {
+ return *c;
+ }
+
+ lua_getfield(s,i,MUTIL_OUTPUT_CHANNEL_FUNCTION);
+
+ if (!lua_isfunction(s,-1))
+ {
+ lua_pop(s,1);
+ return NULL;
+ }
+
+ lua_pushvalue(s,i);
+ lua_call(s,1,1);
+
+ if ((c = luaL_testudata(s,-1,MELISMA_OUTPUT_CHANNEL_TYPENAME)) != NULL)
+ {
+ lua_pop(s,1);
+ return *c;
+ }
+
+ lua_pop(s,1);
+ return NULL;
+}
+
+static ml_channel *mutil_get_channel(lua_State *s, int i)
+{
+ ml_channel *c = mutil_get_optchannel(s,i);
+ if (c == NULL)
+ {
+ luaL_argerror(s,i,"expected output channel");
+ }
+ return c;
+}
+
+#endif
diff --git a/mod/null.c b/mod/null.c
new file mode 100644
index 0000000..815d622
--- /dev/null
+++ b/mod/null.c
@@ -0,0 +1,76 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <assert.h>
+#include <string.h>
+#include <lua.h>
+#include <lauxlib.h>
+#include "melisma.h"
+#include "mutil.h"
+
+#define NULL_TYPENAME "null"
+
+static void proc(const ml_proc_args *args)
+{
+ assert(args->ocnt == 1);
+ memset(args->out[0],0,sizeof(*args->out[0])*args->nframes);
+}
+
+static int l_init(lua_State *s)
+{
+ ml_node *node = ml_create_node(proc,NULL);
+ ml_label_node(node,"null");
+
+ (void) ml_add_output(node);
+
+ *(void**) lua_newuserdata(s,sizeof(void*)) = node;
+ luaL_setmetatable(s,NULL_TYPENAME);
+
+ return 1;
+}
+
+static int l_out(lua_State *s)
+{
+ ml_node *node = *(void**) luaL_checkudata(s,1,NULL_TYPENAME);
+ *(void**) lua_newuserdata(s,sizeof(void*)) = ml_get_output(node,0);
+ luaL_setmetatable(s,MELISMA_OUTPUT_CHANNEL_TYPENAME);
+ return 1;
+}
+
+int luaopen_melisma_null(lua_State *s)
+{
+ ml_check_version();
+
+ lua_newtable(s);
+ int tbl = lua_gettop(s);
+
+ lua_pushcfunction(s,l_init);
+ lua_setfield(s,tbl,"init");
+
+ lua_pushcfunction(s,l_out);
+ lua_setfield(s,tbl,MUTIL_OUTPUT_CHANNEL_FUNCTION);
+
+ luaL_newmetatable(s,NULL_TYPENAME);
+ int mt = lua_gettop(s);
+
+ lua_pushvalue(s,tbl);
+ lua_setfield(s,mt,"__index");
+
+ lua_settop(s,tbl);
+ return 1;
+}
diff --git a/mod/nullsink.c b/mod/nullsink.c
new file mode 100644
index 0000000..0cbd810
--- /dev/null
+++ b/mod/nullsink.c
@@ -0,0 +1,111 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <string.h>
+#include <lua.h>
+#include <lauxlib.h>
+#include "melisma.h"
+#include "mutil.h"
+
+#define NULLSINK_TYPENAME "nullsink"
+
+static int l_init(lua_State *s)
+{
+ ml_node *node = ml_create_node(NULL,NULL);
+ ml_label_node(node,"nullsink");
+ ml_node *root = ml_get_root_node();
+
+ ml_channel *src = ml_add_output(node);
+ ml_channel *dst = ml_add_input(root);
+
+ ml_connect(src,dst);
+
+ *(void**) lua_newuserdata(s,sizeof(void*)) = node;
+ luaL_setmetatable(s,NULLSINK_TYPENAME);
+
+ return 1;
+}
+
+static int l_ref(lua_State *s)
+{
+ ml_node *node = *(void**) luaL_checkudata(s,1,NULLSINK_TYPENAME);
+ ml_channel *src = mutil_get_channel(s,2);
+ ml_channel *dst = ml_add_input(node);
+ ml_connect(src,dst);
+ *(void**) lua_newuserdata(s,sizeof(void*)) = node;
+ luaL_setmetatable(s,NULLSINK_TYPENAME);
+ return 1;
+}
+
+int luaopen_melisma_nullsink(lua_State *s)
+{
+ ml_check_version();
+
+/*** Reference otherwise disconnected nodes.
+
+ local mnullsink = require 'melisma.nullsink'
+ local minterp = require 'melisma.interp'
+ local mevent = require 'melisma.event'
+
+ local interp = minterp.init(0)
+ interp:linear(1,60)
+
+ -- The output of interp is disconnected.
+ -- Need to connect it to a nullsink so we
+ -- can sample it in the event callback below.
+ local nullsink = mnullsink.init()
+ nullsink:ref(interp)
+
+ mevent.schedule(30, function()
+ print('current value:', interp:sample())
+ end)
+
+@classmod nullsink
+*/
+ lua_newtable(s);
+ int tbl = lua_gettop(s);
+
+/*** Create a nullsink.
+@static
+@function init
+@treturn nullsink The newly created nullsink.
+*/
+ lua_pushcfunction(s,l_init);
+ lua_setfield(s,tbl,"init");
+
+/*** Connect an input signal.
+
+This makes sure the input signal is processed
+even if it's not connected to anything else.
+
+@function ref
+@tparam channel input Input signal.
+@treturn nullsink
+*/
+ lua_pushcfunction(s,l_ref);
+ lua_setfield(s,tbl,"ref");
+
+ luaL_newmetatable(s,NULLSINK_TYPENAME);
+ int mt = lua_gettop(s);
+
+ lua_pushvalue(s,tbl);
+ lua_setfield(s,mt,"__index");
+
+ lua_settop(s,tbl);
+ return 1;
+}
diff --git a/mod/oscil.c b/mod/oscil.c
new file mode 100644
index 0000000..7bc39c7
--- /dev/null
+++ b/mod/oscil.c
@@ -0,0 +1,438 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <stdlib.h>
+#include <string.h>
+#include <math.h>
+
+#include <lua.h>
+#include <lauxlib.h>
+
+#include "melisma.h"
+#include "mutil.h"
+
+#define WAVETABLE_TYPENAME "wavetable"
+#define OSCIL_TYPENAME "oscil"
+
+#ifndef WAVETABLE_SIZE
+#define WAVETABLE_SIZE 2048
+#endif
+
+#define WAVETABLE_ALLOC_SIZE (sizeof(float)*WAVETABLE_SIZE)
+
+struct oscil
+{
+ ml_node *node;
+ unsigned long clk;
+ float f;
+ float a;
+ int mf;
+ int ma;
+ float p;
+ float v;
+ float tbl[WAVETABLE_SIZE];
+};
+
+static float *push_new_wavetable(lua_State *s)
+{
+ float *ptr = lua_newuserdata(s,WAVETABLE_ALLOC_SIZE);
+ luaL_setmetatable(s,WAVETABLE_TYPENAME);
+ memset(ptr,0,WAVETABLE_ALLOC_SIZE);
+ return ptr;
+}
+
+static int l_const(lua_State *s)
+{
+ float v = luaL_checknumber(s,1);
+ float *tbl = push_new_wavetable(s);
+
+ for (size_t i = 0; i < WAVETABLE_SIZE; i++)
+ {
+ tbl[i] = v;
+ }
+
+ return 1;
+}
+
+static int l_sin(lua_State *s)
+{
+ float *tbl = push_new_wavetable(s);
+
+ for (size_t i = 0; i < WAVETABLE_SIZE; i++)
+ {
+ float f = 2.f * M_PI * (float) i / (float) WAVETABLE_SIZE;
+ tbl[i] = sinf(f);
+ }
+
+ return 1;
+}
+
+static int l_saw(lua_State *s)
+{
+ float *tbl = push_new_wavetable(s);
+
+ for (size_t i = 0; i < WAVETABLE_SIZE; i++)
+ {
+ tbl[i] = 2 * (float) i / (float) WAVETABLE_SIZE - 1;
+ }
+
+ return 1;
+}
+
+static int l_square(lua_State *s)
+{
+ float *tbl = push_new_wavetable(s);
+
+ for (size_t i = 0; i < WAVETABLE_SIZE; i++)
+ {
+ if (i >= WAVETABLE_SIZE/2)
+ {
+ tbl[i] = 1;
+ }
+ else
+ {
+ tbl[i] = -1;
+ }
+ }
+
+ return 1;
+}
+
+static int l_shift(lua_State *s)
+{
+ float *src = luaL_checkudata(s,1,WAVETABLE_TYPENAME);
+ float p = luaL_checknumber(s,2);
+
+ while (p < 0)
+ {
+ p += 2*M_PI;
+ }
+
+ while (p > 2*M_PI)
+ {
+ p -= 2*M_PI;
+ }
+
+ size_t offset = p/2.f/M_PI*WAVETABLE_SIZE;
+ float *dst = push_new_wavetable(s);
+
+ size_t i,d;
+ for (i=0, d=offset; i < WAVETABLE_SIZE; i++, d++)
+ {
+ dst[d % WAVETABLE_SIZE] = src[i];
+ }
+
+ return 1;
+}
+
+static int l_scale(lua_State *s)
+{
+ float *src = luaL_checkudata(s,1,WAVETABLE_TYPENAME);
+ int n = luaL_checkinteger(s,2);
+
+ // if (n <= 0 || (n & (n-1)) != 0)
+ // {
+ // luaL_error(s,"scaling factor must be a power of two");
+ // }
+
+ float *dst = push_new_wavetable(s);
+
+ for (size_t i = 0; i < WAVETABLE_SIZE; i++)
+ {
+ size_t j = (n*i) % WAVETABLE_SIZE;
+ dst[i] = src[j];
+ }
+
+ return 1;
+}
+
+static int l_add(lua_State *s)
+{
+ float *lhs = luaL_checkudata(s,1,WAVETABLE_TYPENAME);
+ float *rhs = luaL_checkudata(s,2,WAVETABLE_TYPENAME);
+ float *res = push_new_wavetable(s);
+
+ for (size_t i = 0; i < WAVETABLE_SIZE; i++)
+ {
+ res[i] = lhs[i] + rhs[i];
+ }
+
+ return 1;
+}
+
+static void proc(const ml_proc_args *args)
+{
+ struct oscil *oscil = args->userp;
+
+ unsigned long sr = ml_sample_rate();
+ float p = oscil->p;
+ float *dst = args->out[0];
+
+ float *mf = oscil->mf < 0 ? NULL : args->in[oscil->mf];
+ float *ma = oscil->ma < 0 ? NULL : args->in[oscil->ma];
+
+ for (size_t i = 0; i < args->nframes; i++)
+ {
+ float f = mf ? mf[i] : oscil->f;
+ float a = ma ? ma[i] : oscil->a;
+
+ float t = p*WAVETABLE_SIZE;
+
+ size_t i0 = (size_t) t;
+ size_t i1 = i0 + 1;
+
+ float ip = t - i0;
+
+ i0 &= (WAVETABLE_SIZE-1);
+ i1 &= (WAVETABLE_SIZE-1);
+
+ float s0 = oscil->tbl[i0];
+ float s1 = oscil->tbl[i1];
+
+ dst[i] = a*(s0 + ip*(s1 - s0));
+
+ p += f/sr;
+
+ if (p > 1)
+ {
+ p -= 1;
+ }
+ }
+
+ oscil->p = p;
+ oscil->v = dst[args->nframes-1];
+}
+
+static int l_init(lua_State *s)
+{
+ float *tbl = luaL_checkudata(s,1,WAVETABLE_TYPENAME);
+ float f = luaL_checknumber(s,2);
+ float a = luaL_checknumber(s,3);
+
+ struct oscil *oscil = calloc(1,sizeof(*oscil));
+ oscil->clk = 0;
+ oscil->f = f;
+ oscil->a = a;
+ oscil->mf = -1;
+ oscil->ma = -1;
+ oscil->p = 0;
+ oscil->v = a*tbl[0];
+
+ ml_node *node = ml_create_node(proc,oscil);
+ ml_label_node(node,"oscil");
+ ml_add_output(node);
+ oscil->node = node;
+
+ memcpy(oscil->tbl,tbl,WAVETABLE_ALLOC_SIZE);
+
+ *(void**) lua_newuserdata(s,sizeof(void*)) = oscil;
+ luaL_setmetatable(s,OSCIL_TYPENAME);
+
+ return 1;
+}
+
+static int l_setf(lua_State *s)
+{
+ struct oscil *oscil = *(void**) luaL_checkudata(s,1,OSCIL_TYPENAME);
+ float v = luaL_checknumber(s,2);
+ oscil->f = v;
+ return 0;
+}
+
+static int l_seta(lua_State *s)
+{
+ struct oscil *oscil = *(void**) luaL_checkudata(s,1,OSCIL_TYPENAME);
+ float v = luaL_checknumber(s,2);
+ oscil->a = v;
+ return 0;
+}
+
+static int l_mapf(lua_State *s)
+{
+ struct oscil *oscil = *(void**) luaL_checkudata(s,1,OSCIL_TYPENAME);
+ ml_channel *src = mutil_get_channel(s,2);
+
+ oscil->mf = ml_num_inputs(oscil->node);
+
+ ml_channel *dst = ml_add_input(oscil->node);
+ ml_connect(src,dst);
+
+ return 0;
+}
+
+static int l_mapa(lua_State *s)
+{
+ struct oscil *oscil = *(void**) luaL_checkudata(s,1,OSCIL_TYPENAME);
+ ml_channel *src = mutil_get_channel(s,2);
+
+ oscil->ma = ml_num_inputs(oscil->node);
+
+ ml_channel *dst = ml_add_input(oscil->node);
+ ml_connect(src,dst);
+
+ return 0;
+}
+
+static int l_sample(lua_State *s)
+{
+ struct oscil *oscil = *(void**) luaL_checkudata(s,1,OSCIL_TYPENAME);
+ lua_pushnumber(s,oscil->v);
+ return 1;
+}
+
+static int l_out(lua_State *s)
+{
+ struct oscil *oscil = *(void**) luaL_checkudata(s,1,OSCIL_TYPENAME);
+ *(void**) lua_newuserdata(s,sizeof(void*)) = ml_get_output(oscil->node,0);
+ luaL_setmetatable(s,MELISMA_OUTPUT_CHANNEL_TYPENAME);
+ return 1;
+}
+
+int luaopen_melisma_oscil(lua_State *s)
+{
+
+/*** Wavetable backed oscillators.
+
+ local moscil = require 'melisma.oscil'
+ local mexpression = require 'melisma.expression'
+
+ local wt = moscil.wavetable.sin()
+
+ -- Make an LFO for controlling the frequency.
+ local lfo_oscil = moscil.init(wt,1,1)
+
+ -- Map [-1, 1] to [440, 880]
+ local lfo = (440 + (1 + mexpression.input(lfo_oscil,0) / 2) * 440):build()
+
+ local oscil = moscil.init(wt,0,0.5)
+ oscil:mapf(lfo)
+
+@classmod oscil
+*/
+ lua_newtable(s);
+ int tbl = lua_absindex(s,lua_gettop(s));
+
+/*** Initialize an oscillator.
+@static
+@function init
+@tparam wavetable wt
+@tparam number freq
+@tparam number amp
+@treturn oscil
+*/
+ lua_pushcfunction(s,l_init);
+ lua_setfield(s,tbl,"init");
+
+/*** Set oscillator frequency.
+@function setf
+@tparam number freq
+*/
+ lua_pushcfunction(s,l_setf);
+ lua_setfield(s,tbl,"setf");
+
+/*** Set oscillator amplitude.
+@function seta
+@tparam number amp
+*/
+ lua_pushcfunction(s,l_seta);
+ lua_setfield(s,tbl,"seta");
+
+/*** Map a signal to the frequency.
+@function mapf
+@tparam channel sig
+*/
+ lua_pushcfunction(s,l_mapf);
+ lua_setfield(s,tbl,"mapf");
+
+/*** Map a signal to the amplitude.
+@function mapa
+@tparam channel sig
+*/
+ lua_pushcfunction(s,l_mapa);
+ lua_setfield(s,tbl,"mapa");
+
+/*** Get the current oscillator value.
+@function sample
+@treturn number
+*/
+ lua_pushcfunction(s,l_sample);
+ lua_setfield(s,tbl,"sample");
+
+ lua_pushcfunction(s,l_out);
+ lua_setfield(s,tbl,MUTIL_OUTPUT_CHANNEL_FUNCTION);
+
+ luaL_newmetatable(s,OSCIL_TYPENAME);
+ int oscil_mt = lua_gettop(s);
+
+ lua_pushvalue(s,tbl);
+ lua_setfield(s,oscil_mt,"__index");
+
+/***
+@type wavetable
+*/
+ lua_newtable(s);
+ int wt_tbl = lua_absindex(s,lua_gettop(s));
+
+ lua_pushvalue(s,wt_tbl);
+ lua_setfield(s,tbl,"wavetable");
+
+ lua_pushcfunction(s,l_const);
+ lua_setfield(s,wt_tbl,"const");
+
+/*** Sine wave.
+@static
+@function wavetable.sin
+@treturn wavetable
+*/
+ lua_pushcfunction(s,l_sin);
+ lua_setfield(s,wt_tbl,"sin");
+
+/*** Saw wave.
+@static
+@function wavetable.saw
+@treturn wavetable
+*/
+ lua_pushcfunction(s,l_saw);
+ lua_setfield(s,wt_tbl,"saw");
+
+/*** Square wave.
+@static
+@function wavetable.square
+@treturn wavetable
+*/
+ lua_pushcfunction(s,l_square);
+ lua_setfield(s,wt_tbl,"square");
+
+ lua_pushcfunction(s,l_shift);
+ lua_setfield(s,wt_tbl,"shift");
+
+ lua_pushcfunction(s,l_scale);
+ lua_setfield(s,wt_tbl,"scale");
+
+ luaL_newmetatable(s,WAVETABLE_TYPENAME);
+ int wt_mt = lua_gettop(s);
+
+ lua_pushvalue(s,wt_tbl);
+ lua_setfield(s,wt_mt,"__index");
+
+ lua_pushcfunction(s,l_add);
+ lua_setfield(s,wt_mt,"__add");
+
+ lua_settop(s,tbl);
+ return 1;
+}
diff --git a/mod/pan.c b/mod/pan.c
new file mode 100644
index 0000000..84a0551
--- /dev/null
+++ b/mod/pan.c
@@ -0,0 +1,187 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <string.h>
+#include <stdlib.h>
+#include <stdbool.h>
+#include <lauxlib.h>
+#include "melisma.h"
+#include "mutil.h"
+
+#define PAN_TYPENAME "pan"
+
+struct pan
+{
+ ml_channel *l_out;
+ ml_channel *r_out;
+ float val;
+ bool map;
+};
+
+static void proc(const ml_proc_args *args)
+{
+ struct pan *pan = args->userp;
+
+ float val,*in,*l_out,*r_out,*map;
+ in = args->in[0];
+ l_out = args->out[0];
+ r_out = args->out[1];
+ map = pan->map ? args->in[1] : NULL;
+
+ for (size_t i = 0; i < args->nframes; i++)
+ {
+ val = map ? map[i] : pan->val;
+ l_out[i] = val*in[i];
+ r_out[i] = (1 - val)*in[i];
+ }
+}
+
+static int l_init(lua_State *s)
+{
+ ml_channel *dst,*src,*map_dst,*map_src = NULL;
+ float val = 0;
+
+ if (lua_isuserdata(s,1))
+ {
+ map_src = mutil_get_channel(s,1);
+ }
+ else
+ {
+ val = luaL_checknumber(s,1);
+ }
+
+ src = mutil_get_channel(s,2);
+
+ struct pan *pan = calloc(1,sizeof(*pan));
+ pan->val = val;
+ pan->map = map_src != NULL;
+
+ ml_node *node = ml_create_node(proc,pan);
+ ml_label_node(node,"pan");
+
+ dst = ml_add_input(node);
+ pan->l_out = ml_add_output(node);
+ pan->r_out = ml_add_output(node);
+
+ ml_connect(src,dst);
+
+ if (pan->map)
+ {
+ map_dst = ml_add_input(node);
+ ml_connect(map_src,map_dst);
+ }
+
+ *(void**) lua_newuserdata(s,sizeof(void*)) = pan;
+ luaL_setmetatable(s,PAN_TYPENAME);
+
+ return 1;
+}
+
+static int l_left(lua_State *s)
+{
+ struct pan *pan = *(void**) luaL_checkudata(s,1,PAN_TYPENAME);
+ *(void**) lua_newuserdata(s,sizeof(void*)) = pan->l_out;
+ luaL_setmetatable(s,MELISMA_OUTPUT_CHANNEL_TYPENAME);
+ return 1;
+}
+
+static int l_right(lua_State *s)
+{
+ struct pan *pan = *(void**) luaL_checkudata(s,1,PAN_TYPENAME);
+ *(void**) lua_newuserdata(s,sizeof(void*)) = pan->r_out;
+ luaL_setmetatable(s,MELISMA_OUTPUT_CHANNEL_TYPENAME);
+ return 1;
+}
+
+static int l_set(lua_State *s)
+{
+ struct pan *pan = *(void**) luaL_checkudata(s,1,PAN_TYPENAME);
+ pan->val = luaL_checknumber(s,2);
+ return 0;
+}
+
+int luaopen_melisma_pan(lua_State *s)
+{
+ ml_check_version();
+
+/*** Panning
+
+ local mpan = require 'melisma.pan'
+ local mexpression = require 'melisma.expression'
+ local moscil = require 'melisma.oscil'
+
+ local wt = moscil.wavetable.sin()
+ local lfo_oscil = moscil.init(wt,1,1)
+ local lfo = (1 + mexpression.input(lfo_oscil,0) / 2):build()
+
+ local oscil = moscil.init(wt,440,0.5)
+ local pan = mpan.init(lfo,oscil)
+
+ local left = pan:left()
+ local right = pan:right()
+
+@classmod pan
+*/
+ lua_newtable(s);
+ int tbl = lua_gettop(s);
+
+/*** Initialize a panning unit.
+
+A panning value ranges from 0 to 1, where a value of 0
+will pan everything to the left.
+
+@static
+@function init
+@tparam number|channel x Constant pan or a controlling signal.
+@tparam channel sig Input signal.
+@treturn pan The newly created panning unit.
+*/
+ lua_pushcfunction(s,l_init);
+ lua_setfield(s,tbl,"init");
+
+/*** Get the left output signal.
+@function left
+@treturn channel
+*/
+ lua_pushcfunction(s,l_left);
+ lua_setfield(s,tbl,"left");
+
+
+/*** Get the right output signal.
+@function right
+@treturn channel
+*/
+ lua_pushcfunction(s,l_right);
+ lua_setfield(s,tbl,"right");
+
+/*** Set the panning value.
+@function set
+@tparam number x
+*/
+ lua_pushcfunction(s,l_set);
+ lua_setfield(s,tbl,"set");
+
+ luaL_newmetatable(s,PAN_TYPENAME);
+ int mt = lua_gettop(s);
+
+ lua_pushvalue(s,tbl);
+ lua_setfield(s,mt,"__index");
+
+ lua_settop(s,tbl);
+ return 1;
+}
diff --git a/mod/passthrough.c b/mod/passthrough.c
new file mode 100644
index 0000000..775c6fe
--- /dev/null
+++ b/mod/passthrough.c
@@ -0,0 +1,82 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <assert.h>
+#include <string.h>
+#include <lua.h>
+#include <lauxlib.h>
+#include "melisma.h"
+#include "mutil.h"
+
+#define PASSTHROUGH_TYPENAME "passthrough"
+
+static void proc(const ml_proc_args *args)
+{
+ assert(args->icnt == 1);
+ assert(args->ocnt == 1);
+ memcpy(args->out[0],args->in[0],args->nframes*sizeof(float));
+}
+
+static int l_init(lua_State *s)
+{
+ ml_channel *src = mutil_get_channel(s,1);
+
+ ml_node *node = ml_create_node(proc,NULL);
+ ml_label_node(node,"passthrough");
+
+ ml_channel *dst = ml_add_input(node);
+ ml_connect(src,dst);
+
+ (void) ml_add_output(node);
+
+ *(void**) lua_newuserdata(s,sizeof(void*)) = node;
+ luaL_setmetatable(s,PASSTHROUGH_TYPENAME);
+
+ return 1;
+}
+
+static int l_out(lua_State *s)
+{
+ ml_node *node = *(void**) luaL_checkudata(s,1,PASSTHROUGH_TYPENAME);
+ *(void**) lua_newuserdata(s,sizeof(void*)) = ml_get_output(node,0);
+ luaL_setmetatable(s,MELISMA_OUTPUT_CHANNEL_TYPENAME);
+ return 1;
+}
+
+int luaopen_melisma_passthrough(lua_State *s)
+{
+ ml_check_version();
+
+ lua_newtable(s);
+ int tbl = lua_gettop(s);
+
+ lua_pushcfunction(s,l_init);
+ lua_setfield(s,tbl,"init");
+
+ lua_pushcfunction(s,l_out);
+ lua_setfield(s,tbl,MUTIL_OUTPUT_CHANNEL_FUNCTION);
+
+ luaL_newmetatable(s,PASSTHROUGH_TYPENAME);
+ int mt = lua_gettop(s);
+
+ lua_pushvalue(s,tbl);
+ lua_setfield(s,mt,"__index");
+
+ lua_settop(s,tbl);
+ return 1;
+}
diff --git a/mod/playback.c b/mod/playback.c
new file mode 100644
index 0000000..f9d22ca
--- /dev/null
+++ b/mod/playback.c
@@ -0,0 +1,342 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <stdlib.h>
+#include <string.h>
+#include <stdbool.h>
+#include <lauxlib.h>
+#include "melisma.h"
+#include "mutil.h"
+#include "sample.h"
+
+#define PLAYBACK_TYPENAME "playback"
+
+struct playback
+{
+ ml_node *node;
+ struct sample *smp;
+ double speed;
+ double phase;
+ bool active;
+ bool loop;
+ int fn;
+ int arg;
+};
+
+static void proc(const ml_proc_args *args)
+{
+ struct playback *pb = args->userp;
+
+ if (pb->smp == NULL)
+ {
+ memset(args->out[0],0,sizeof(*args->out[0])*args->nframes);
+ return;
+ }
+
+ unsigned long sr = ml_sample_rate();
+ double phase = pb->phase;
+ float *dst = args->out[0];
+ size_t len = pb->smp->len;
+ float *data = pb->smp->data;
+
+ for (size_t i = 0; i < args->nframes; i++)
+ {
+ if (!pb->active)
+ {
+ memset(&dst[i],0,(args->nframes - i)*sizeof(*dst));
+ return;
+ }
+
+ float t = phase*len;
+
+ size_t i0 = (size_t) t;
+ size_t i1 = i0 + 1;
+ float ip = t - i0;
+
+ i0 %= len;
+ i1 %= len;
+
+ float s0 = data[i0];
+ float s1 = data[i1];
+
+ dst[i] = s0 + ip*(s1 - s0);
+
+ phase += pb->speed/len;
+
+ while (phase > 1)
+ {
+ phase -= 1;
+
+ if (pb->fn != LUA_NOREF)
+ {
+ ml_schedule_event(0,pb->fn,pb->arg);
+
+ }
+
+ if (!pb->loop)
+ {
+ pb->active = false;
+ pb->phase = 0;
+ }
+ }
+
+ while (phase < 0)
+ {
+ phase += 1;
+
+ if (pb->fn != LUA_NOREF)
+ {
+ ml_schedule_event(0,pb->fn,pb->arg);
+
+ }
+
+ if (!pb->loop)
+ {
+ pb->active = false;
+ pb->phase = 0;
+ }
+ }
+ }
+
+ pb->phase = phase;
+}
+
+static int l_init(lua_State *s)
+{
+ struct sample *smp = NULL;
+
+ if (!lua_isnoneornil(s,1))
+ {
+ smp = *(void**) luaL_checkudata(s,1,SAMPLE_TYPENAME);
+ }
+
+ struct playback *pb = calloc(1,sizeof(*pb));
+
+ pb->smp = smp;
+ pb->speed = 1;
+ pb->fn = LUA_NOREF;
+ pb->arg = LUA_NOREF;
+ pb->node = ml_create_node(proc,pb);
+ ml_label_node(pb->node,"playback (%s)",smp ? smp->path : "NULL");
+
+ ml_add_output(pb->node);
+
+ *(void**) lua_newuserdata(s,sizeof(void*)) = pb;
+ luaL_setmetatable(s,PLAYBACK_TYPENAME);
+
+ return 1;
+}
+
+static int l_set_sample(lua_State *s)
+{
+ struct playback *pb = *(void**) luaL_checkudata(s,1,PLAYBACK_TYPENAME);
+ struct sample *smp = *(void**) luaL_checkudata(s,2,SAMPLE_TYPENAME);
+ pb->smp = smp;
+ return 0;
+}
+
+static int l_play(lua_State *s)
+{
+ struct playback *pb = *(void**) luaL_checkudata(s,1,PLAYBACK_TYPENAME);
+ pb->active = true;
+ return 0;
+}
+
+static int l_pause(lua_State *s)
+{
+ struct playback *pb = *(void**) luaL_checkudata(s,1,PLAYBACK_TYPENAME);
+ pb->active = false;
+ return 0;
+}
+
+static int l_reset(lua_State *s)
+{
+ struct playback *pb = *(void**) luaL_checkudata(s,1,PLAYBACK_TYPENAME);
+ pb->phase = 0;
+ return 0;
+}
+
+static int l_stop(lua_State *s)
+{
+ struct playback *pb = *(void**) luaL_checkudata(s,1,PLAYBACK_TYPENAME);
+ pb->phase = 0;
+ pb->active = false;
+ return 0;
+}
+
+static int l_speed(lua_State *s)
+{
+ struct playback *pb = *(void**) luaL_checkudata(s,1,PLAYBACK_TYPENAME);
+
+ if (!lua_isnil(s,2))
+ {
+ pb->speed = luaL_checknumber(s,2);
+ }
+
+ lua_pushnumber(s,pb->speed);
+ return 1;
+}
+
+static int l_loop(lua_State *s)
+{
+ struct playback *pb = *(void**) luaL_checkudata(s,1,PLAYBACK_TYPENAME);
+
+ if (!lua_isnil(s,2))
+ {
+ luaL_checktype(s,2,LUA_TBOOLEAN);
+ pb->loop = lua_toboolean(s,2);
+ }
+
+ lua_pushboolean(s,pb->loop);
+ return 1;
+}
+
+static int l_phase(lua_State *s)
+{
+ struct playback *pb = *(void**) luaL_checkudata(s,1,PLAYBACK_TYPENAME);
+
+ if (!lua_isnil(s,2))
+ {
+ double phase = luaL_checknumber(s,2);
+
+ if (phase > 1)
+ {
+ phase = 1;
+ }
+
+ if (phase < 0)
+ {
+ phase = 0;
+ }
+
+ pb->phase = phase;
+ }
+
+ lua_pushnumber(s,pb->phase);
+ return 1;
+}
+
+static int l_out(lua_State *s)
+{
+ struct playback *pb = *(void**) luaL_checkudata(s,1,PLAYBACK_TYPENAME);
+ *(void**) lua_newuserdata(s,sizeof(void*)) = ml_get_output(pb->node,0);
+ luaL_setmetatable(s,MELISMA_OUTPUT_CHANNEL_TYPENAME);
+ return 1;
+}
+
+int luaopen_melisma_playback(lua_State *s)
+{
+/*** Audio sample playback.
+
+ local msample = require 'melisma.sample'
+ local mplayback = require 'melisma.playback'
+
+ local sample = msample.load('sample.wav')
+ local playb = mplayback.init(sample)
+
+ playb:loop(true)
+ playb:spped(2)
+ playb:play()
+
+@classmod playback
+*/
+ lua_newtable(s);
+ int tbl = lua_gettop(s);
+
+/*** Intialize a playback unit.
+@static
+@function init
+@tparam sample|nil smp
+@treturn playback
+*/
+ lua_pushcfunction(s,l_init);
+ lua_setfield(s,tbl,"init");
+
+/*** Set playback sample.
+@function set_sample
+@tparam sample smp
+*/
+ lua_pushcfunction(s,l_set_sample);
+ lua_setfield(s,tbl,"set_sample");
+
+/*** Start playback.
+@function play
+*/
+ lua_pushcfunction(s,l_play);
+ lua_setfield(s,tbl,"play");
+
+/*** Pause playback.
+@function pause
+*/
+ lua_pushcfunction(s,l_pause);
+ lua_setfield(s,tbl,"pause");
+
+/*** Stop playback.
+
+Equivalent to @{playback.pause}; @{playback.reset}.
+
+@function stop
+*/
+ lua_pushcfunction(s,l_stop);
+ lua_setfield(s,tbl,"stop");
+
+
+/*** Reset playback.
+
+Equivalent to @{playback.phase}(0).
+
+@function reset
+*/
+ lua_pushcfunction(s,l_reset);
+ lua_setfield(s,tbl,"reset");
+
+/*** Set playback speed.
+
+A negative speed will play the sample in reverse.
+
+@function reset
+*/
+ lua_pushcfunction(s,l_speed);
+ lua_setfield(s,tbl,"speed");
+
+/*** Enable/disable automatic looping.
+@function loop
+@tparam bool b
+*/
+ lua_pushcfunction(s,l_loop);
+ lua_setfield(s,tbl,"loop");
+
+/*** Set playback phase.
+@function phase
+@tparam number x Phase ranging from 0 to 1.
+*/
+ lua_pushcfunction(s,l_phase);
+ lua_setfield(s,tbl,"phase");
+
+ lua_pushcfunction(s,l_out);
+ lua_setfield(s,tbl,MUTIL_OUTPUT_CHANNEL_FUNCTION);
+
+ luaL_newmetatable(s,PLAYBACK_TYPENAME);
+ int mt = lua_gettop(s);
+
+ lua_pushvalue(s,tbl);
+ lua_setfield(s,mt,"__index");
+
+ lua_settop(s,tbl);
+ return 1;
+}
diff --git a/mod/sample.c b/mod/sample.c
new file mode 100644
index 0000000..6a3a24b
--- /dev/null
+++ b/mod/sample.c
@@ -0,0 +1,180 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <stdlib.h>
+#include <string.h>
+#include <lauxlib.h>
+#include <sndfile.h>
+#include <samplerate.h>
+#include "melisma.h"
+#include "sample.h"
+
+static struct sample *alloc_sample(size_t len)
+{
+ struct sample *s = malloc(sizeof(*s) + len*sizeof(s->data[0]));
+ s->len = len;
+ return s;
+}
+
+static struct sample *resample(struct sample *old, unsigned long from, unsigned long to)
+{
+ double ratio = (double)to / (double)from;
+ size_t new_len = ratio * old->len;
+ struct sample *new = alloc_sample(new_len);
+
+ SRC_DATA src_data = {
+ .data_in = old->data,
+ .data_out = new->data,
+ .input_frames = old->len,
+ .output_frames = new->len,
+ .src_ratio = ratio,
+ };
+
+ src_simple(&src_data,SRC_SINC_MEDIUM_QUALITY,1);
+
+ new->path = old->path;
+ free(old);
+
+ return new;
+}
+
+static int l_load(lua_State *s)
+{
+ const char *path = luaL_checkstring(s,1);
+
+ SF_INFO info;
+ SNDFILE *f;
+
+ f = sf_open(path,SFM_READ,&info);
+
+ if (f == NULL)
+ {
+ luaL_error(s,"failed to open audio file '%s': %s",path,sf_strerror(f));
+ }
+
+ size_t len = info.frames*info.channels;
+ float *data = malloc(sizeof(*data)*len);
+
+ size_t off = 0;
+ while (off < len)
+ {
+ ssize_t res = sf_read_float(f,&data[off],len - off);
+
+ if (res < 0)
+ {
+ luaL_error(s,"failed to open audio file '%s': %s",path,sf_strerror(f));
+ }
+
+ off += res;
+ }
+
+ sf_close(f);
+
+ size_t samplerate = ml_sample_rate();
+
+ for (size_t c = 0; c < info.channels; c++)
+ {
+ struct sample *smp = alloc_sample(info.frames);
+ smp->path = strdup(path);
+
+ for (size_t i = 0; i < info.frames; i++)
+ {
+ smp->data[i] = data[i*info.channels + c];
+ }
+
+ if (info.samplerate != samplerate)
+ {
+ smp = resample(smp,info.samplerate,samplerate);
+ }
+
+ *(void**) lua_newuserdata(s,sizeof(void*)) = smp;
+ luaL_setmetatable(s,SAMPLE_TYPENAME);
+ }
+
+ free(data);
+
+ return info.channels;
+}
+
+static int l_length(lua_State *s)
+{
+ struct sample *smp = *(void**) luaL_checkudata(s,1,SAMPLE_TYPENAME);
+ unsigned long sr = ml_sample_rate();
+ double l = smp->len / (double)sr;
+ lua_pushnumber(s,l);
+ return 1;
+}
+
+static int l_path(lua_State *s)
+{
+ struct sample *smp = *(void**) luaL_checkudata(s,1,SAMPLE_TYPENAME);
+ lua_pushstring(s,smp->path);
+ return 1;
+}
+
+int luaopen_melisma_sample(lua_State *s)
+{
+/*** Load audio samples.
+
+ local msample = require 'melisma.sample'
+
+ local smp = msample.load('sample.wav')
+
+ print(smp:length())
+ print(smp:path())
+
+@classmod sample
+*/
+ lua_newtable(s);
+ int tbl = lua_gettop(s);
+
+/*** Load an audio sample.
+
+If necessary, the audio will be resampled to the current sample rate.
+
+@static
+@function load
+@tparam string path
+@treturn sample
+*/
+ lua_pushcfunction(s,l_load);
+ lua_setfield(s,tbl,"load");
+
+/*** Get sample length.
+@function length
+@treturn number The length of the sample in seconds.
+*/
+ lua_pushcfunction(s,l_length);
+ lua_setfield(s,tbl,"length");
+
+/*** Get sample path.
+@function path
+@treturn string The path that the sample was loaded from.
+*/
+ lua_pushcfunction(s,l_path);
+ lua_setfield(s,tbl,"path");
+
+ luaL_newmetatable(s,SAMPLE_TYPENAME);
+ int mt = lua_gettop(s);
+
+ lua_pushvalue(s,tbl);
+ lua_setfield(s,mt,"__index");
+
+ lua_settop(s,tbl);
+ return 1;
+}
diff --git a/mod/sample.h b/mod/sample.h
new file mode 100644
index 0000000..af2ec83
--- /dev/null
+++ b/mod/sample.h
@@ -0,0 +1,33 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#ifndef SAMPLE_H_INCLUDED
+#define SAMPLE_H_INCLUDED
+
+#include <stddef.h>
+
+#define SAMPLE_TYPENAME "audio sample"
+
+struct sample
+{
+ char *path;
+ size_t len;
+ float data[];
+};
+
+#endif
diff --git a/mod/serial.c b/mod/serial.c
new file mode 100644
index 0000000..a7b51db
--- /dev/null
+++ b/mod/serial.c
@@ -0,0 +1,511 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <string.h>
+#include <assert.h>
+#include <stdlib.h>
+#include <errno.h>
+#include <ctype.h>
+#include <unistd.h>
+#include <termios.h>
+#include <fcntl.h>
+#include <lauxlib.h>
+#include "melisma.h"
+#include "melisma/ring.h"
+
+#define SERIAL_TYPENAME "serial"
+#define DEFAULT_BAUD 9600
+#define DEFAULT_FORMAT "8N1"
+#define DEFAULT_ENCODING "cstring"
+#define DEFAULT_BUFSIZE 4096
+
+struct serial;
+
+typedef int (*decode_fn)(struct serial*);
+
+struct conf
+{
+ char *path;
+ char *format;
+ int datasize;
+ int stopbits;
+ int parity;
+ int baud;
+ int baudflag;
+ decode_fn decode;
+ int bufsize;
+ int callb;
+};
+
+struct serial
+{
+ struct conf conf;
+ int fd;
+ int synced;
+ size_t len;
+ size_t cap;
+ uint8_t *data;
+ uint8_t *tmpdata;
+ ml_ring *ring;
+ lua_State *state;
+};
+
+static int cstring(struct serial*);
+
+static const struct {
+ decode_fn decode;
+ const char *name;
+} encoding_map[] = {
+ { cstring, "cstring" },
+ { NULL, NULL },
+};
+
+static void free_conf(struct conf *c)
+{
+ free(c->path);
+ free(c->format);
+}
+
+static void put_byte(struct serial *se, int c)
+{
+ if (se->len == se->cap)
+ {
+ se->cap = se->cap == 0 ? 32 : se->cap*2;
+ se->data = realloc(se->data,se->cap);
+ se->tmpdata = realloc(se->tmpdata,se->cap);
+ }
+ se->data[se->len++] = c;
+}
+
+static void io_handler(int fd, int events, void *args)
+{
+ struct serial *se = args;
+ if (se->conf.decode(se) == 0)
+ {
+ if (ml_ring_has_space(se->ring,se->len + sizeof se->len))
+ {
+ ml_ring_write_blocking(se->ring,&se->len,sizeof se->len);
+ ml_ring_write_blocking(se->ring,se->data,se->len);
+ }
+ else
+ {
+ fprintf(stderr,"dropped %lu bytes\n",se->len);
+ }
+ }
+ else
+ {
+ fprintf(stderr,"failed to decode message\n");
+ }
+ se->len = 0;
+}
+
+static void read_conf(lua_State *s, int tbl, struct conf *c)
+{
+ int top = lua_gettop(s);
+ lua_getfield(s,tbl,"path");
+
+ if (lua_isnil(s,-1))
+ {
+ free_conf(c);
+ luaL_error(s,"missing 'path' field");
+ }
+
+ if (!lua_isstring(s,-1))
+ {
+ free_conf(c);
+ luaL_error(s,"'path' field is not a string");
+ }
+
+ c->path = strdup(lua_tostring(s,-1));
+ lua_settop(s,top);
+
+
+ lua_getfield(s,tbl,"baud");
+
+ if (lua_isnil(s,-1))
+ {
+ c->baud = DEFAULT_BAUD;
+ }
+ else if (lua_isinteger(s,-1))
+ {
+ c->baud = lua_tointeger(s,-1);
+ }
+ else
+ {
+ free_conf(c);
+ luaL_error(s,"'baud' field is not an integer");
+ }
+
+ switch (c->baud)
+ {
+ case 0: c->baudflag = B0; break;
+ case 50: c->baudflag = B50; break;
+ case 75: c->baudflag = B75; break;
+ case 110: c->baudflag = B110; break;
+ case 134: c->baudflag = B134; break;
+ case 150: c->baudflag = B150; break;
+ case 200: c->baudflag = B200; break;
+ case 300: c->baudflag = B300; break;
+ case 600: c->baudflag = B600; break;
+ case 1200: c->baudflag = B1200; break;
+ case 1800: c->baudflag = B1800; break;
+ case 2400: c->baudflag = B2400; break;
+ case 4800: c->baudflag = B4800; break;
+ case 9600: c->baudflag = B9600; break;
+ case 19200: c->baudflag = B19200; break;
+ case 38400: c->baudflag = B38400; break;
+ case 57600: c->baudflag = B57600; break;
+ case 115200: c->baudflag = B115200; break;
+ case 230400: c->baudflag = B230400; break;
+ case 460800: c->baudflag = B460800; break;
+ case 500000: c->baudflag = B500000; break;
+ case 576000: c->baudflag = B576000; break;
+ case 921600: c->baudflag = B921600; break;
+ case 1000000: c->baudflag = B1000000; break;
+ case 1152000: c->baudflag = B1152000; break;
+ case 1500000: c->baudflag = B1500000; break;
+ case 2000000: c->baudflag = B2000000; break;
+ default: c->baudflag = -1; break;
+ }
+
+ if (c->baudflag < 0)
+ {
+ free_conf(c);
+ luaL_error(s,"unsupported baud rate %d",c->baud);
+ }
+
+ lua_settop(s,top);
+
+ const char *format;
+ lua_getfield(s,tbl,"format");
+
+ if (lua_isnil(s,-1))
+ {
+ format = DEFAULT_FORMAT;
+ }
+ else if (lua_isstring(s,-1))
+ {
+ format = lua_tostring(s,-1);
+ }
+ else
+ {
+ free_conf(c);
+ luaL_error(s,"'format' field is not a string");
+ }
+
+ c->format = strdup(format);
+
+ if (strlen(format) != 3 ||
+ !strchr("5678",format[0]) ||
+ !strchr("NEOneo",format[1]) ||
+ !strchr("12",format[2]))
+ {
+ free_conf(c);
+ luaL_error(s,"invalid frame format '%s'",format);
+ }
+
+ c->datasize = format[0] - '0';
+ c->parity = toupper(format[1]);
+ c->stopbits = format[2] - '0';
+
+ lua_settop(s,top);
+
+ lua_getfield(s,tbl,"encoding");
+ const char *encoding = NULL;
+
+ if (lua_isnil(s,-1))
+ {
+ encoding = DEFAULT_ENCODING;
+ }
+ else if (lua_isstring(s,-1))
+ {
+ encoding = lua_tostring(s,-1);
+ }
+ else
+ {
+ free_conf(c);
+ luaL_error(s,"'encoding' field is not a string");
+ }
+
+ for (size_t i = 0;; i++)
+ {
+ c->decode = encoding_map[i].decode;
+ const char *name = encoding_map[i].name;
+
+ if (c->decode == NULL)
+ {
+ free_conf(c);
+ luaL_error(s,"unknown encoding '%s'",encoding);
+ }
+
+ if (strcmp(name,encoding) == 0)
+ {
+ break;
+ }
+ }
+
+ lua_getfield(s,tbl,"bufsize");
+
+ if (lua_isnil(s,-1))
+ {
+ c->bufsize = DEFAULT_BUFSIZE;
+ }
+ else if (lua_isinteger(s,-1))
+ {
+ c->bufsize = lua_tointeger(s,-1);
+
+ if (c->bufsize <= 0)
+ {
+ luaL_error(s,"invalid bufsize %d",c->bufsize);
+ }
+ }
+ else
+ {
+ free_conf(c);
+ luaL_error(s,"'bufsize' is not an integer");
+ }
+
+ lua_getfield(s,tbl,"callback");
+
+ if (lua_isnil(s,-1))
+ {
+ c->callb = LUA_NOREF;
+ }
+ else if (lua_isfunction(s,-1))
+ {
+ c->callb = luaL_ref(s,LUA_REGISTRYINDEX);
+ }
+ else
+ {
+ free_conf(c);
+ luaL_error(s,"'callback' is not a function");
+ }
+}
+
+static void setup_tty(struct termios *t, struct conf *c)
+{
+ cfsetispeed(t,c->baudflag);
+
+ switch (c->stopbits)
+ {
+ case 1: t->c_cflag &= ~CSTOPB; break;
+ case 2: t->c_cflag |= CSTOPB; break;
+ default: assert(0);
+ }
+
+ switch (c->parity)
+ {
+ case 'N':
+ case 'n':
+ t->c_cflag &= ~PARENB;
+ break;
+ case 'O':
+ case 'o':
+ t->c_cflag |= PARENB;
+ t->c_cflag |= PARODD;
+ break;
+ case 'E':
+ case 'e':
+ t->c_cflag |= PARENB;
+ t->c_cflag &= ~PARODD;
+ break;
+ default:
+ assert(0);
+ }
+
+ t->c_cflag &= ~CSIZE;
+
+ switch (c->datasize)
+ {
+ case 5: t->c_cflag |= CS5; break;
+ case 6: t->c_cflag |= CS6; break;
+ case 7: t->c_cflag |= CS7; break;
+ case 8: t->c_cflag |= CS8; break;
+ default: assert(0);
+ }
+
+ t->c_cflag |= CREAD|CLOCAL;
+ t->c_cflag &= ~CRTSCTS;
+ t->c_lflag &= ~(ICANON|ECHO|ISIG);
+ t->c_iflag &= ~(IXON|IXOFF|IXANY);
+ t->c_oflag &= ~OPOST;
+ t->c_cc[VMIN] = 1;
+ t->c_cc[VTIME] = 0;
+}
+
+static void proc(const ml_proc_args *args)
+{
+ struct serial *se = args->userp;
+ size_t len;
+
+ while (ml_ring_read(se->ring,&len,sizeof len) == sizeof len)
+ {
+ ml_ring_read_blocking(se->ring,se->tmpdata,len);
+
+ if (se->conf.callb != LUA_NOREF)
+ {
+ lua_pushlstring(se->state,(const char*)se->tmpdata,len);
+ int arg = luaL_ref(se->state,LUA_REGISTRYINDEX);
+ ml_schedule_event(0,se->conf.callb,arg);
+ }
+ }
+}
+
+static int l_open(lua_State *s)
+{
+ luaL_checktype(s,1,LUA_TTABLE);
+
+ struct conf conf = {0};
+
+ read_conf(s,1,&conf);
+
+ int fd = open(conf.path,O_RDONLY|O_NOCTTY);
+
+ if (fd < 0)
+ {
+ luaL_error(s,"failed to open %s: %s",conf.path,strerror(errno));
+ }
+
+ struct termios tty = {0};
+ tcgetattr(fd,&tty);
+
+ setup_tty(&tty,&conf);
+
+ tcsetattr(fd,TCSANOW,&tty);
+ tcflush(fd,TCIFLUSH);
+
+ struct serial *se = calloc(1,sizeof(*se));
+ se->fd = fd;
+ se->conf = conf;
+ se->ring = ml_ring_init(conf.bufsize);
+ se->state = s;
+
+ *(void**) lua_newuserdata(s,sizeof(void**)) = se;
+ luaL_setmetatable(s,SERIAL_TYPENAME);
+
+ ml_add_io_handler(fd,POLLIN,io_handler,se);
+
+ ml_node *node = ml_create_node(proc,se);
+ ml_channel *src = ml_add_output(node);
+ ml_channel *dst = ml_add_input(ml_get_root_node());
+ ml_connect(src,dst);
+ ml_label_node(node,"serial %s",conf.path);
+
+ return 1;
+}
+
+static int read_byte(struct serial *se)
+{
+ uint8_t c;
+
+ if (read(se->fd,&c,sizeof c) < sizeof c)
+ {
+ return -1;
+ }
+
+ return c;
+}
+
+static int sync_(struct serial *se)
+{
+ int c;
+
+ if (se->synced)
+ {
+ return 0;
+ }
+
+ while ((c = read_byte(se)) != 0)
+ {
+ if (c < 0)
+ {
+ return -1;
+ }
+ }
+
+ se->synced = 1;
+ return 0;
+}
+
+static int cstring(struct serial *se)
+{
+ if (sync_(se) < 0)
+ {
+ return -1;
+ }
+
+ int c;
+
+ while ((c = read_byte(se)) != 0)
+ {
+ if (c < 0)
+ {
+ se->synced = 0;
+ return -1;
+ }
+
+ put_byte(se,c);
+ }
+
+ return 0;
+}
+
+int luaopen_melisma_serial(lua_State *s)
+{
+/*** Read data on serial ports.
+
+ local mserial = require 'melisma.serial'
+
+ mserial.open {
+ path = '/dev/ttyACM0',
+ callback = print,
+ }
+
+@classmod serial
+*/
+ lua_newtable(s);
+ int tbl = lua_gettop(s);
+
+/*** Open a serial port.
+
+`conf` is a table containing the following keys:
+
+- `path` (**string, required**): Path to serial port (e.g. `/dev/ttyACM0`).
+- `callback` (**function, required**): Callback function for handling messages.
+- `baud` (**integer**); baud rate (default `9600`).
+- `format` (**string**): frame format (default `'8N1'`).
+- `encoding` (**string**): method for encoding messages (default `'cstring'`). May be one of:
+ - `'cstring'`: `NULL`-terminated strings.
+
+@static
+@function open
+@tparam table conf
+@treturn serial
+*/
+ lua_pushcfunction(s,l_open);
+ lua_setfield(s,tbl,"open");
+
+ luaL_newmetatable(s,SERIAL_TYPENAME);
+ int mt = lua_gettop(s);
+
+ lua_pushvalue(s,tbl);
+ lua_setfield(s,mt,"__index");
+
+ lua_pushvalue(s,tbl);
+ return 1;
+}
diff --git a/mod/spring.c b/mod/spring.c
new file mode 100644
index 0000000..a6f96ea
--- /dev/null
+++ b/mod/spring.c
@@ -0,0 +1,173 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <stdlib.h>
+#include <stdbool.h>
+#include <math.h>
+#include <lauxlib.h>
+#include "melisma.h"
+
+#define SPRING_TYPENAME "spring"
+
+struct spring
+{
+ ml_node *node;
+ float target;
+ float value;
+ float freq;
+ float damping;
+ float velocity;
+ bool mapped;
+};
+
+static void proc(const ml_proc_args *args)
+{
+ struct spring *spring = args->userp;
+
+ float *out = args->out[0];
+ float *mapped = spring->mapped ? args->in[0] : NULL;
+ float dt = 1.f/ml_sample_rate();
+
+ for (size_t i = 0; i < args->nframes; i++)
+ {
+ float target = mapped ? mapped[i] : spring->target;
+ float w = 2*M_PI*spring->freq;
+ float f = (target - spring->value)*w*w;
+ float d = 2*w*spring->velocity*spring->damping;
+ float a = f - d;
+
+ spring->velocity += a*dt;
+ spring->value += spring->velocity*dt;
+
+ out[i] = spring->value;
+ }
+}
+
+static int l_damping(lua_State *s)
+{
+ struct spring *spring = *(void**) luaL_checkudata(s,1,SPRING_TYPENAME);
+ float x = luaL_checknumber(s,2);
+ spring->damping = x;
+ return 0;
+}
+
+static int l_freq(lua_State *s)
+{
+ struct spring *spring = *(void**) luaL_checkudata(s,1,SPRING_TYPENAME);
+ float x = luaL_checknumber(s,2);
+ spring->freq = x;
+ return 0;
+}
+
+static int l_target(lua_State *s)
+{
+ struct spring *spring = *(void**) luaL_checkudata(s,1,SPRING_TYPENAME);
+ float x = luaL_checknumber(s,2);
+ spring->target = x;
+ return 0;
+}
+
+static int l_map(lua_State *s)
+{
+ struct spring *spring = *(void**) luaL_checkudata(s,1,SPRING_TYPENAME);
+
+ if (spring->mapped)
+ {
+ luaL_error(s,"already mapped");
+ }
+
+ ml_channel *src = *(void**) luaL_checkudata(s,2,MELISMA_OUTPUT_CHANNEL_TYPENAME);
+ ml_channel *dst = ml_add_input(spring->node);
+ ml_connect(src,dst);
+
+ spring->mapped = true;
+
+ return 0;
+}
+
+static int l_sample(lua_State *s)
+{
+ struct spring *spring = *(void**) luaL_checkudata(s,1,SPRING_TYPENAME);
+ lua_pushnumber(s,spring->value);
+ return 1;
+}
+
+static int l_call(lua_State *s)
+{
+ struct spring *spring = *(void**) luaL_checkudata(s,1,SPRING_TYPENAME);
+ *(void**) lua_newuserdata(s,sizeof(void*)) = ml_get_output(spring->node,0);
+ luaL_setmetatable(s,MELISMA_OUTPUT_CHANNEL_TYPENAME);
+ return 1;
+}
+
+static int l_init(lua_State *s)
+{
+ float value = luaL_checknumber(s,1);
+ float freq = luaL_checknumber(s,2);
+ float damping = luaL_checknumber(s,3);
+
+ struct spring *spring = calloc(1,sizeof(*spring));
+ spring->value = value;
+ spring->freq = freq;
+ spring->damping = damping;
+ spring->mapped = false;
+
+ spring->node = ml_create_node(proc,spring);
+ ml_add_output(spring->node);
+
+ *(void**) lua_newuserdata(s,sizeof(void*)) = spring;
+ luaL_setmetatable(s,SPRING_TYPENAME);
+
+ return 1;
+}
+
+int luaopen_melisma_spring(lua_State *s)
+{
+ lua_newtable(s);
+ int tbl = lua_gettop(s);
+
+ lua_pushcfunction(s,l_init);
+ lua_setfield(s,tbl,"init");
+
+ lua_pushcfunction(s,l_freq);
+ lua_setfield(s,tbl,"freq");
+
+ lua_pushcfunction(s,l_damping);
+ lua_setfield(s,tbl,"damping");
+
+ lua_pushcfunction(s,l_target);
+ lua_setfield(s,tbl,"target");
+
+ lua_pushcfunction(s,l_sample);
+ lua_setfield(s,tbl,"sample");
+
+ lua_pushcfunction(s,l_map);
+ lua_setfield(s,tbl,"map");
+
+ luaL_newmetatable(s,SPRING_TYPENAME);
+ int mt = lua_gettop(s);
+
+ lua_pushvalue(s,tbl);
+ lua_setfield(s,mt,"__index");
+
+ lua_pushcfunction(s,l_call);
+ lua_setfield(s,mt,"__call");
+
+ lua_settop(s,tbl);
+ return 1;
+}
diff --git a/mod/timer.c b/mod/timer.c
new file mode 100644
index 0000000..92659dd
--- /dev/null
+++ b/mod/timer.c
@@ -0,0 +1,152 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <stdlib.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <lauxlib.h>
+#include "melisma.h"
+
+#define TIMER_TYPENAME "timer"
+
+struct timer
+{
+ bool on;
+ uint64_t t;
+};
+
+static void proc(const ml_proc_args *args)
+{
+ struct timer *tmr = args->userp;
+
+ if (tmr->on)
+ {
+ tmr->t += args->nframes;
+ }
+}
+
+static int l_init(lua_State *s)
+{
+ struct timer *tmr = calloc(1,sizeof(*tmr));
+
+ tmr->on = false;
+ tmr->t = 0;
+
+ ml_node *node = ml_create_node(proc,tmr);
+ ml_channel *src = ml_add_output(node);
+ ml_channel *dst = ml_add_input(ml_get_root_node());
+ ml_connect(src,dst);
+
+ *(void**) lua_newuserdata(s,sizeof(void*)) = tmr;
+ luaL_setmetatable(s,TIMER_TYPENAME);
+
+ return 1;
+}
+
+static int l_start(lua_State *s)
+{
+ struct timer *tmr = *(void**) luaL_checkudata(s,1,TIMER_TYPENAME);
+ tmr->on = true;
+ return 0;
+}
+
+static int l_stop(lua_State *s)
+{
+ struct timer *tmr = *(void**) luaL_checkudata(s,1,TIMER_TYPENAME);
+ tmr->on = false;
+ return 0;
+}
+
+static int l_reset(lua_State *s)
+{
+ struct timer *tmr = *(void**) luaL_checkudata(s,1,TIMER_TYPENAME);
+ tmr->t = 0;
+ return 0;
+}
+
+static int l_read(lua_State *s)
+{
+ struct timer *tmr = *(void**) luaL_checkudata(s,1,TIMER_TYPENAME);
+ double t = (double) tmr->t / (double) ml_sample_rate();
+ lua_pushnumber(s,t);
+ return 1;
+}
+
+int luaopen_melisma_timer(lua_State *s)
+{
+/*** Time measurement.
+
+ local mtimer = require 'melisma.timer'
+ local mkeycallback = require 'melisma.keycallback'
+
+ local timer = mtimer.init()
+ timer:start()
+
+ mkeycallback.set(function()
+ print(timer:read())
+ timer:reset()
+ end)
+
+@classmod timer
+*/
+ lua_newtable(s);
+ int tbl = lua_gettop(s);
+
+/*** Create a new timer.
+@static
+@function init
+@treturn timer
+*/
+ lua_pushcfunction(s,l_init);
+ lua_setfield(s,tbl,"init");
+
+/*** Start timer.
+@function start
+*/
+ lua_pushcfunction(s,l_start);
+ lua_setfield(s,tbl,"start");
+
+/*** Stop timer.
+@function stop
+*/
+ lua_pushcfunction(s,l_stop);
+ lua_setfield(s,tbl,"stop");
+
+/*** Reset timer.
+@function reset
+*/
+ lua_pushcfunction(s,l_reset);
+ lua_setfield(s,tbl,"reset");
+
+
+/*** Read timer.
+@function read
+@treturn number Elapsed seconds.
+*/
+ lua_pushcfunction(s,l_read);
+ lua_setfield(s,tbl,"read");
+
+ luaL_newmetatable(s,TIMER_TYPENAME);
+ int mt = lua_gettop(s);
+
+ lua_pushvalue(s,tbl);
+ lua_setfield(s,mt,"__index");
+
+ lua_settop(s,tbl);
+ return 1;
+}
diff --git a/mod/udp.c b/mod/udp.c
new file mode 100644
index 0000000..6448a56
--- /dev/null
+++ b/mod/udp.c
@@ -0,0 +1,391 @@
+/*
+ * melisma - extensible and scriptable audio engine
+ * Copyright (C) 2026 Ludvig Gunne Lindström
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <stdlib.h>
+#include <string.h>
+#include <errno.h>
+#include <assert.h>
+
+#include <unistd.h>
+#include <sys/eventfd.h>
+#include <sys/socket.h>
+#include <sys/un.h>
+#include <netinet/in.h>
+#include <arpa/inet.h>
+
+#include <lauxlib.h>
+
+#include "melisma.h"
+#include "melisma/ring.h"
+
+#define SOCKADDR_TYPENAME "sockaddr"
+#define UDP_SOCKET_TYPENAME "udp socket"
+#define UDP_PACKET_SIZE 65536
+
+struct udp_socket
+{
+ int fd;
+ int family;
+ int event;
+ int bound;
+ int callb;
+ lua_State *state;
+ ml_ring *recvring;
+ ml_ring *sendring;
+ uint8_t pollbuf[UDP_PACKET_SIZE];
+ uint8_t procbuf[UDP_PACKET_SIZE];
+};
+
+// TODO: CHECK BUFFER SIZES FOR ADDRESSES
+
+static int l_sockaddr_unix(lua_State *s)
+{
+ size_t len;
+ const char *path = luaL_checklstring(s,1,&len);
+
+ struct sockaddr_storage *sa = lua_newuserdata(s,sizeof *sa);
+ luaL_setmetatable(s,SOCKADDR_TYPENAME);
+ memset(sa,0,sizeof *sa);
+
+ ((struct sockaddr_un*)sa)->sun_family = AF_UNIX;
+ memcpy(((struct sockaddr_un*)sa)->sun_path,path,len);
+
+ return 1;
+}
+
+static int l_sockaddr_inet(lua_State *s)
+{
+ const char *addr = luaL_checkstring(s,1);
+ int port = luaL_checkinteger(s,2);
+
+ struct in_addr in_addr;
+ if (inet_pton(AF_INET,addr,&in_addr) != 1)
+ {
+ luaL_error(s,"invalid ipv4 address '%s'",addr);
+ }
+
+ struct sockaddr_storage *sa = lua_newuserdata(s,sizeof *sa);
+ luaL_setmetatable(s,SOCKADDR_TYPENAME);
+ memset(sa,0,sizeof *sa);
+
+ ((struct sockaddr_in*)sa)->sin_family = AF_INET;
+ ((struct sockaddr_in*)sa)->sin_port = htons(port);
+ memcpy(&((struct sockaddr_in*)sa)->sin_addr,&in_addr,sizeof in_addr);
+
+ return 1;
+}
+
+static void recv_handler(int fd, int flags, void *args)
+{
+ struct udp_socket *us = args;
+ ssize_t len = recvfrom(fd,us->pollbuf,sizeof us->pollbuf,0,NULL,NULL);
+
+ if (len < 0)
+ {
+ perror("recvfrom");
+ return;
+ }
+
+ if (!ml_ring_has_space(us->recvring,len + sizeof len))
+ {
+ fprintf(stderr,"dropped udp packet");
+ }
+
+ ml_ring_write_blocking(us->recvring,&len,sizeof len);
+ ml_ring_write_blocking(us->recvring,us->pollbuf,len);
+}
+
+static void send_handler(int fd, int flags, void *args)
+{
+ struct udp_socket *us = args;
+
+ size_t sa_len,len;
+ struct sockaddr_storage storage = {0};
+
+ ml_ring_read_blocking(us->sendring,&sa_len,sizeof sa_len);
+ ml_ring_read_blocking(us->sendring,&storage,sa_len);
+ ml_ring_read_blocking(us->sendring,&len,sizeof len);
+ ml_ring_read_blocking(us->sendring,us->pollbuf,len);
+
+ if (sendto(us->fd,us->pollbuf,len,0,(struct sockaddr*) &storage,sa_len) < 0)
+ {
+ perror("sendto");
+ }
+
+ uint64_t x;
+ if (read(us->event,&x,sizeof x) != sizeof x)
+ ;
+}
+
+static int l_socket(lua_State *s)
+{
+ int family = lua_tointeger(s,lua_upvalueindex(1));
+ int fd = socket(family,SOCK_DGRAM,0);
+
+ if (fd < 0)
+ {
+ const char *err = strerror(errno);
+ luaL_error(s,"failed to create socket: %s",err);
+ }
+
+ struct udp_socket *us = calloc(1,sizeof(*us));
+ us->fd = fd;
+ us->family = family;
+ us->sendring = ml_ring_init(UDP_PACKET_SIZE);
+ us->event = eventfd(0,EFD_SEMAPHORE);
+
+ ml_add_io_handler(us->event,POLLIN,send_handler,us);
+
+ *(struct udp_socket**) lua_newuserdata(s,sizeof(struct udp_socket*)) = us;
+ luaL_setmetatable(s,UDP_SOCKET_TYPENAME);
+
+ return 1;
+}
+
+static const char *family_name(int family)
+{
+ switch (family)
+ {
+ case AF_UNIX: return "unix";
+ case AF_INET: return "inet";
+ default: assert(0);
+ }
+}
+
+static void proc(const ml_proc_args *args)
+{
+ struct udp_socket *us = args->userp;
+ ssize_t len;
+
+ while (!ml_ring_empty(us->recvring))
+ {
+ ml_ring_read_blocking(us->recvring,&len,sizeof len);
+ ml_ring_read_blocking(us->recvring,us->procbuf,len);
+
+ lua_pushlstring(us->state,(const char*)us->procbuf,len);
+ int arg = luaL_ref(us->state,LUA_REGISTRYINDEX);
+
+ ml_schedule_event(0,us->callb,arg);
+ }
+}
+
+static size_t get_sockaddr_len(struct sockaddr *sa)
+{
+ if (sa->sa_family == AF_UNIX)
+ {
+ const char *path = ((struct sockaddr_un*)sa)->sun_path;
+
+ if (path[0] == 0 && path[1] != 0)
+ {
+ return offsetof(struct sockaddr_un,sun_path) + strlen(path+1) + 1;
+ }
+
+ return offsetof(struct sockaddr_un,sun_path) + strlen(path);
+ }
+ else
+ {
+ return sizeof(struct sockaddr_in);
+ }
+}
+
+static void check_family(lua_State *s, struct udp_socket *us, struct sockaddr *sa)
+{
+ if (us->family != sa->sa_family)
+ {
+ luaL_error(s,"sockaddr address family (%s) does not match socket address family (%s)",
+ family_name(sa->sa_family), family_name(us->family));
+ }
+}
+
+static int l_bind(lua_State *s)
+{
+ struct udp_socket *us = *(struct udp_socket**) luaL_checkudata(s,1,UDP_SOCKET_TYPENAME);
+ struct sockaddr *sa = luaL_checkudata(s,2,SOCKADDR_TYPENAME);
+
+ luaL_checktype(s,3,LUA_TFUNCTION);
+
+ if (us->bound)
+ {
+ luaL_error(s,"socket is already bound");
+ }
+
+ check_family(s,us,sa);
+
+ size_t len = get_sockaddr_len(sa);
+
+ if (bind(us->fd,sa,len) < 0)
+ {
+ const char *err = strerror(errno);
+ luaL_error(s,"failed to bind socket: %s",err);
+ }
+
+ us->bound = 1;
+ us->recvring = ml_ring_init(UDP_PACKET_SIZE);
+ us->state = s;
+
+ ml_add_io_handler(us->fd,POLLIN,recv_handler,us);
+
+ lua_pushvalue(s,3);
+ us->callb = luaL_ref(s,LUA_REGISTRYINDEX);
+
+ ml_node *node = ml_create_node(proc,us);
+ ml_channel *src = ml_add_output(node);
+ ml_channel *dst = ml_add_input(ml_get_root_node());
+ ml_connect(src,dst);
+
+ return 0;
+}
+
+static int l_send(lua_State *s)
+{
+ struct udp_socket *us = *(struct udp_socket**) luaL_checkudata(s,1,UDP_SOCKET_TYPENAME);
+ struct sockaddr *sa = luaL_checkudata(s,2,SOCKADDR_TYPENAME);
+ size_t len;
+ const char *data = luaL_checklstring(s,3,&len);
+
+ check_family(s,us,sa);
+
+ size_t sa_len = get_sockaddr_len(sa);
+ size_t full_len = sizeof len + len + sizeof sa_len + sa_len;
+
+ if (!ml_ring_has_space(us->sendring,full_len))
+ {
+ lua_pushboolean(s,0);
+ return 1;
+ }
+
+ ml_ring_write_blocking(us->sendring,&sa_len,sizeof sa_len);
+ ml_ring_write_blocking(us->sendring,sa,sa_len);
+ ml_ring_write_blocking(us->sendring,&len,sizeof len);
+ ml_ring_write_blocking(us->sendring,data,len);
+
+ uint64_t x = 1;
+ if (write(us->event,&x,sizeof x) < sizeof x)
+ ;
+
+ lua_pushboolean(s,1);
+ return 1;
+}
+
+int luaopen_melisma_udp(lua_State *s)
+{
+ lua_newtable(s);
+ int tbl = lua_gettop(s);
+
+/*** UDP support.
+
+ local mudp = require 'melisma.udp'
+
+ local addr = mudp.sockaddr.inet('127.0.0.1',8000)
+ local sock = mudp.socket.inet()
+
+ sock:send(addr,'hello')
+ sock:bind(addr,print)
+
+@module udp
+*/
+
+/*** Socket address description.
+@type sockaddr
+*/
+ lua_newtable(s);
+ int sockaddr_tbl = lua_gettop(s);
+
+/*** Create a unix domain address description.
+@static
+@function unix
+@tparam string path
+@treturn sockaddr
+*/
+ lua_pushcfunction(s,l_sockaddr_unix);
+ lua_setfield(s,sockaddr_tbl,"unix");
+
+
+/*** Create a internet domain address description.
+@static
+@function inet
+@tparam string host
+@tparam integer port
+@treturn sockaddr
+*/
+ lua_pushcfunction(s,l_sockaddr_inet);
+ lua_setfield(s,sockaddr_tbl,"inet");
+
+ lua_pushvalue(s,sockaddr_tbl);
+ lua_setfield(s,tbl,"sockaddr");
+
+ luaL_newmetatable(s,SOCKADDR_TYPENAME);
+ int sockaddr_mt = lua_gettop(s);
+
+ lua_pushvalue(s,sockaddr_tbl);
+ lua_setfield(s,sockaddr_mt,"__index");
+
+/*** Handle to a socket.
+@type socket
+*/
+
+ lua_newtable(s);
+ int socket_tbl = lua_gettop(s);
+
+/*** Create a unix domain socket.
+@static
+@function unix
+@treturn socket
+*/
+ lua_pushinteger(s,AF_UNIX);
+ lua_pushcclosure(s,l_socket,1);
+ lua_setfield(s,socket_tbl,"unix");
+
+
+/*** Create an internet domain socket.
+@static
+@function inet
+@treturn socket
+*/
+ lua_pushinteger(s,AF_INET);
+ lua_pushcclosure(s,l_socket,1);
+ lua_setfield(s,socket_tbl,"inet");
+
+/*** Bind a socket to an address.
+@function bind
+@tparam sockaddr addr Address description.
+@tparam function fn Packet callback function.
+*/
+ lua_pushcfunction(s,l_bind);
+ lua_setfield(s,socket_tbl,"bind");
+
+/*** Send a packet on a socket.
+@function send
+@tparam sockaddr addr Address description.
+@tparam string pkt Packet to send.
+*/
+ lua_pushcfunction(s,l_send);
+ lua_setfield(s,socket_tbl,"send");
+
+ lua_pushvalue(s,socket_tbl);
+ lua_setfield(s,tbl,"socket");
+
+ luaL_newmetatable(s,UDP_SOCKET_TYPENAME);
+ int socket_mt = lua_gettop(s);
+
+ lua_pushvalue(s,socket_tbl);
+ lua_setfield(s,socket_mt,"__index");
+
+ lua_settop(s,tbl);
+ return 1;
+}