-- 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;