cc-libs/packages/trapos-music/apis/libaudio.lua
2026-06-20 14:15:54 +02:00

204 lines
6.2 KiB
Lua

-- libaudio: simple stateful DSP filters for 8-bit PCM audio buffers.
--
-- A factory: `local createAudio = require('/apis/libaudio'); local audio = createAudio();`
--
-- Each constructor returns a *filter instance* operating on a buffer (a table of
-- amplitudes -128..127, the same shape the dfpwm decoder produces and the
-- speaker consumes). Filters are stateful across chunks -- one instance per
-- playback, exactly like a dfpwm decoder. They share a uniform shape so a UI can
-- drive any of them generically:
--
-- filter.process(buffer) -- in-place transform, returns the buffer
-- filter.set(value) -- set the primary tunable param (keeps history)
-- filter.get() -- current param value
-- filter.reset() -- clear internal state (call on track switch)
-- filter.spec -- { name, param, unit, min, max, step, format }
--
-- `sampleRate` is injectable so the delay-based filters are unit-testable with a
-- tiny, exact ring length outside the CC runtime (mirrors libmusic's style).
local DEFAULT_SAMPLE_RATE = 48000;
-- Round to the nearest representable 8-bit sample and clamp to range.
local function clamp(v)
v = math.floor(v + 0.5);
if v > 127 then return 127; end
if v < -128 then return -128; end
return v;
end
local function clampParam(value, spec)
if value < spec.min then return spec.min; end
if value > spec.max then return spec.max; end
return value;
end
local function createAudio(opts)
opts = opts or {};
local sampleRate = opts.sampleRate or DEFAULT_SAMPLE_RATE;
local api = {};
-- One-pole IIR low-pass: y = y_prev + alpha*(x - y_prev). Higher cutoff lets
-- more treble through; very low cutoff muffles the sound to its bass.
function api.lowPass(cutoffHz)
local f = {};
f.spec = { name = 'Low-pass', param = 'Cutoff', unit = 'Hz',
min = 100, max = 12000, step = 100 };
local yPrev = 0; -- float; only the written sample is clamped
local alpha = 0;
local cutoff = 0;
f.set = function(value)
cutoff = clampParam(value, f.spec);
local rc = 1 / (2 * math.pi * cutoff);
local dt = 1 / sampleRate;
alpha = dt / (rc + dt);
end
f.get = function() return cutoff; end
f.reset = function() yPrev = 0; end
f.process = function(buffer)
for i = 1, #buffer do
yPrev = yPrev + alpha * (buffer[i] - yPrev);
buffer[i] = clamp(yPrev);
end
return buffer;
end
f.set(cutoffHz or 800);
return f;
end
-- One-pole IIR high-pass: y = alpha*(y_prev + x - x_prev). Strips bass; high
-- cutoff leaves only a thin, tinny top end.
function api.highPass(cutoffHz)
local f = {};
f.spec = { name = 'High-pass', param = 'Cutoff', unit = 'Hz',
min = 100, max = 12000, step = 100 };
local yPrev = 0;
local xPrev = 0;
local alpha = 0;
local cutoff = 0;
f.set = function(value)
cutoff = clampParam(value, f.spec);
local rc = 1 / (2 * math.pi * cutoff);
local dt = 1 / sampleRate;
alpha = rc / (rc + dt);
end
f.get = function() return cutoff; end
f.reset = function() yPrev = 0; xPrev = 0; end
f.process = function(buffer)
for i = 1, #buffer do
local x = buffer[i];
yPrev = alpha * (yPrev + x - xPrev);
xPrev = x;
buffer[i] = clamp(yPrev);
end
return buffer;
end
f.set(cutoffHz or 2000);
return f;
end
-- Single-tap delay (audio guide section 5): mixes 0.6 of the dry signal with
-- 0.4 of the signal from `time` ms ago, via a ring buffer.
function api.delay(timeMs)
local f = {};
f.spec = { name = 'Delay', param = 'Time', unit = 'ms',
min = 50, max = 1500, step = 50 };
local timeValue = 0;
local ring = {};
local ringLen = 1;
local idx = 1;
local function rebuild()
ringLen = math.max(1, math.floor(timeValue / 1000 * sampleRate + 0.5));
ring = {};
for i = 1, ringLen do ring[i] = 0; end
idx = 1;
end
f.set = function(value)
timeValue = clampParam(value, f.spec);
rebuild();
end
f.get = function() return timeValue; end
f.reset = function() rebuild(); end
f.process = function(buffer)
for i = 1, #buffer do
local original = buffer[i];
local mixed = original * 0.6 + ring[idx] * 0.4;
ring[idx] = original;
idx = idx + 1;
if idx > ringLen then idx = 1; end
buffer[i] = clamp(mixed);
end
return buffer;
end
f.set(timeMs or 300);
return f;
end
-- Schroeder-style reverb: a few parallel feedback comb filters whose decaying
-- echoes are summed into a wet signal, then mixed with the dry. Each comb is
-- y = (1-g)*x + g*y[n-D]: the (1-g) input gain keeps the tap in range whatever
-- the feedback, so `decay` (g) purely controls how long the tail rings out.
-- An impulse leaves echoes at D, 2D, 3D... decaying by g, i.e. an audible tail.
local COMB_MS = { 50, 56, 61, 68 };
function api.reverb(decay)
local f = {};
f.spec = { name = 'Reverb', param = 'Decay', unit = '',
min = 0, max = 0.9, step = 0.1, format = '%.1f' };
local g = 0;
local combs = {};
local function rebuild()
combs = {};
for c = 1, #COMB_MS do
local len = math.max(1, math.floor(COMB_MS[c] / 1000 * sampleRate + 0.5));
local buf = {};
for i = 1, len do buf[i] = 0; end
combs[c] = { buf = buf, len = len, idx = 1 };
end
end
f.set = function(value)
g = clampParam(value, f.spec);
end
f.get = function() return g; end
f.reset = function() rebuild(); end
f.process = function(buffer)
for i = 1, #buffer do
local x = buffer[i];
local wet = 0;
for c = 1, #combs do
local comb = combs[c];
local y = (1 - g) * x + g * comb.buf[comb.idx];
comb.buf[comb.idx] = y;
comb.idx = comb.idx + 1;
if comb.idx > comb.len then comb.idx = 1; end
wet = wet + y;
end
wet = wet / #combs;
buffer[i] = clamp(x * 0.4 + wet * 0.6);
end
return buffer;
end
rebuild();
f.set(decay or 0.5);
return f;
end
return api;
end
return createAudio;