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