204 lines
6.2 KiB
Lua
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;
|