Loading files
3230db654This file is pinned to the selected release. Download the original to open it in your design tool.
// Patternflow Audio — the mapping editor.
//
// One module, no chrome.* and no fetch: everything outside this file arrives
// through window.PFAdapter (editor-adapter.js). The console port copies this
// file verbatim and swaps the adapter.
//
// The model, in one sentence: a band is a BOX drawn on the live spectrum
// (width = the frequencies it listens to, height = the level window it maps),
// and a RESPONSE CURVE that turns position-in-window into position-in-output-
// range. Curves bake to a 33-point table on save; the analysis side — and
// later the firmware — only interpolates, and never learns what a bezier is.
'use strict';
const A = window.PFAdapter;
// ── config ──────────────────────────────────────────────────────────────
const HZ_DEFAULTS = [[60, 250], [250, 2000], [2000, 5000], [5000, 16000]];
const LUT_POINTS = 33;
const PRESETS = {
smooth: { label: 'Smooth', curve: { type: 'bezier', id: 'smooth', y0: 0, y1: 1, p1x: 0.45, p1y: 0.05, p2x: 0.55, p2y: 0.95 } },
sharp: { label: 'Sharp', curve: { type: 'bezier', id: 'sharp', y0: 0, y1: 1, p1x: 0.10, p1y: 0.65, p2x: 0.35, p2y: 1.00 } },
fall: { label: 'Fall', curve: { type: 'bezier', id: 'fall', y0: 1, y1: 0, p1x: 0.45, p1y: 0.95, p2x: 0.55, p2y: 0.05 } },
gate: { label: 'Gate', curve: { type: 'steps', id: 'gate', n: 2 } },
steps: { label: 'Steps', curve: { type: 'steps', id: 'steps', n: 3 } },
arch: { label: 'Arch', curve: { type: 'arch', id: 'arch' } }
};
const clamp01 = (v) => Math.max(0, Math.min(1, Number(v) || 0));
const clampHz = (v) => Math.max(CAPS.hzMin, Math.min(CAPS.hzMax, Number(v) || CAPS.hzMin));
const CAPS = A.caps();
function defaultBand(index) {
const [hzMin, hzMax] = HZ_DEFAULTS[index % HZ_DEFAULTS.length];
return {
hzMin, hzMax,
knob: Math.min(index, 3), muted: false,
inMin: 0, inMax: 1, gain: 1,
outMin: 0.30, outMax: 0.85,
curve: null, lut: null
};
}
function normalizeBand(raw, index) {
const band = { ...defaultBand(index), ...(raw || {}) };
band.hzMin = clampHz(band.hzMin); band.hzMax = clampHz(band.hzMax);
if (band.hzMin > band.hzMax) [band.hzMin, band.hzMax] = [band.hzMax, band.hzMin];
if (band.hzMax - band.hzMin < 10) band.hzMax = Math.min(CAPS.hzMax, band.hzMin + 10);
band.inMin = clamp01(band.inMin); band.inMax = clamp01(band.inMax);
if (band.inMax - band.inMin < 0.02) band.inMax = Math.min(1, band.inMin + 0.02);
band.outMin = clamp01(band.outMin); band.outMax = clamp01(band.outMax);
if (band.outMax - band.outMin < 0.05) band.outMax = Math.min(1, band.outMin + 0.05);
// Four bands, four knobs, one line between them: band i drives knob i.
// A free assignment existed and earned nothing but a control to explain.
band.knob = index;
band.muted = band.muted === true;
band.gain = Math.max(0.2, Math.min(4, Number(band.gain) || 1));
if (band.curve && typeof band.curve !== 'object') band.curve = null;
if (band.lut && (!Array.isArray(band.lut) || band.lut.length < 2)) band.lut = null;
return band;
}
function normalizeConfig(raw) {
const stored = Array.isArray(raw && raw.bands) ? raw.bands : [];
const bands = [];
for (let i = 0; i < 4; i++) bands.push(normalizeBand(stored[i], i));
return {
host: (raw && raw.host) || 'patternflow.local',
smoothing: (raw && raw.smoothing) || 0.35,
sendIntervalMs: (raw && raw.sendIntervalMs) || 33,
autoRange: !!(raw && raw.autoRange),
attack: Math.max(0.05, Math.min(0.9, Number(raw && raw.attack) || 0.65)),
bands,
...(raw && raw.manualExtra ? { manualExtra: raw.manualExtra } : {})
};
}
// ── curve engine ────────────────────────────────────────────────────────
function evalCurve(curve, u) {
u = clamp01(u);
if (!curve) return u;
if (curve.type === 'steps') {
const n = Math.max(2, Math.min(8, Math.round(curve.n) || 2));
const k = Math.min(n - 1, Math.floor(u * n));
return k / (n - 1);
}
if (curve.type === 'arch') return Math.sin(Math.PI * u);
if (curve.type === 'bezier') {
// Cubic from (0, y0) to (1, y1); x(t) is monotone while both control xs
// stay in [0,1], so a short bisection recovers t for any u.
const { y0 = 0, y1 = 1, p1x, p1y, p2x, p2y } = curve;
const bx = (t) => 3 * (1 - t) * (1 - t) * t * p1x + 3 * (1 - t) * t * t * p2x + t * t * t;
const by = (t) => (1 - t) * (1 - t) * (1 - t) * y0 + 3 * (1 - t) * (1 - t) * t * p1y + 3 * (1 - t) * t * t * p2y + t * t * t * y1;
let lo = 0, hi = 1;
for (let i = 0; i < 24; i++) {
const mid = (lo + hi) / 2;
if (bx(mid) < u) lo = mid; else hi = mid;
}
return clamp01(by((lo + hi) / 2));
}
return u;
}
function legacyCurve(band, u) {
return Math.pow(clamp01(u), 1 / band.gain);
}
function bandCurveValue(band, u) {
return band.curve ? evalCurve(band.curve, u) : legacyCurve(band, u);
}
function bakeLut(curve) {
const lut = [];
for (let i = 0; i < LUT_POINTS; i++) {
lut.push(Number(evalCurve(curve, i / (LUT_POINTS - 1)).toFixed(4)));
}
return lut;
}
// ── state ───────────────────────────────────────────────────────────────
let cfg = normalizeConfig(null);
let sel = 0;
let frame = { levels: [], env: [], spectrum: [], running: false, connected: false };
let lastEnv = null; // most recent non-empty env, for the auto→manual handoff
let saveTimer = null;
function persist() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => A.saveConfig(cfg), 150);
}
function touchCurve(band, curve) {
band.curve = curve;
band.lut = bakeLut(curve);
}
// ── main plot geometry ──────────────────────────────────────────────────
const $ = (id) => document.getElementById(id);
const plotCanvas = $('plot');
const pctx = plotCanvas.getContext('2d');
const M = { l: 48, r: 6, t: 22, b: 40 };
let PW = 0, PH = 0; // plot area, css px
function sizePlot() {
const wrap = $('plotWrap');
const cssW = wrap.clientWidth;
const cssH = 400;
const dpr = window.devicePixelRatio || 1;
plotCanvas.width = Math.round(cssW * dpr);
plotCanvas.height = Math.round(cssH * dpr);
plotCanvas.style.width = cssW + 'px';
plotCanvas.style.height = cssH + 'px';
pctx.setTransform(dpr, 0, 0, dpr, 0, 0);
PW = cssW - M.l - M.r;
PH = cssH - M.t - M.b;
}
const DECADES = Math.log10(CAPS.hzMax / CAPS.hzMin);
const xOf = (hz) => M.l + PW * (Math.log10(clampHz(hz) / CAPS.hzMin) / DECADES);
const hzOf = (x) => clampHz(CAPS.hzMin * Math.pow(10, DECADES * ((x - M.l) / PW)));
const yOf = (v) => M.t + (1 - clamp01(v)) * PH;
const vOf = (y) => clamp01(1 - (y - M.t) / PH);
// Inner gridlines only — the endpoints are drawn from CAPS, so the same axis
// code serves the extension's 20–20k and the microphone's 31–8k.
const TICKS = [[50, '50'], [100, '100'], [200, '200'], [500, '500'],
[1000, '1k'], [2000, '2k'], [5000, '5k'], [10000, '10k']]
.filter(([hz]) => hz > CAPS.hzMin * 1.15 && hz < CAPS.hzMax * 0.87);
const DB_LINES = [[1, '0'], [0.75, '-18'], [0.5, '-35'], [0.25, '-53'], [0, '-70']];
// Canvas colors come from the page's CSS variables, read fresh each paint so
// a theme toggle repaints correctly. The extension page is the cream
// instrument; the device console wraps the same module in its dark tokens.
function theme() {
const s = getComputedStyle(document.body);
const v = (name, fallback) => (s.getPropertyValue(name) || fallback).trim();
const led = v('--led', '#e8552e');
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(led.replace('#', ''));
const ledRgb = m ? `${parseInt(m[1], 16)}, ${parseInt(m[2], 16)}, ${parseInt(m[3], 16)}` : '232, 85, 46';
return {
field: v('--field', '#fbf7ef'),
cream: v('--cream', '#f4efe6'),
ink: v('--ink', '#141414'),
muted: v('--muted', '#6b655a'),
faint: v('--faint', '#a69f90'),
rule: v('--rule', '#d9d1c0'),
led,
ledA: (a) => `rgba(${ledRgb}, ${a})`,
ruleA: (a) => `rgba(${parseInt(v('--rule', '#d9d1c0').replace('#', '').slice(0, 2), 16)}, ${parseInt(v('--rule', '#d9d1c0').replace('#', '').slice(2, 4), 16)}, ${parseInt(v('--rule', '#d9d1c0').replace('#', '').slice(4, 6), 16)}, ${a})`,
inkA: (a) => `rgba(${parseInt(v('--ink', '#141414').replace('#', '').slice(0, 2), 16)}, ${parseInt(v('--ink', '#141414').replace('#', '').slice(2, 4), 16)}, ${parseInt(v('--ink', '#141414').replace('#', '').slice(4, 6), 16)}, ${a})`
};
}
// The window a band maps right now: its own handles, or the breathing
// envelope while auto range runs.
function bandWindow(index) {
const band = cfg.bands[index];
if (cfg.autoRange) {
const env = (frame.env && frame.env[index]) || (lastEnv && lastEnv[index]);
if (env) return { lo: clamp01(env.lo), hi: clamp01(env.hi) };
return { lo: 0.1, hi: 0.7 };
}
return { lo: band.inMin, hi: band.inMax };
}
function formatHz(hz) {
return hz >= 1000 ? (hz / 1000).toFixed(hz >= 9950 ? 0 : 1).replace(/\.0$/, '') + 'k' : String(Math.round(hz));
}
// ── main plot drawing ───────────────────────────────────────────────────
const MONO = '10px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
function drawPlot() {
const T = theme();
const W = M.l + PW + M.r, H = M.t + PH + M.b;
pctx.clearRect(0, 0, W, H);
pctx.fillStyle = T.field;
pctx.fillRect(0, 0, W, H);
// grid
pctx.font = MONO;
pctx.strokeStyle = T.ruleA(0.55);
pctx.fillStyle = T.faint;
pctx.lineWidth = 1;
for (const [v, label] of DB_LINES) {
const y = Math.round(yOf(v)) + 0.5;
if (v > 0 && v < 1) {
pctx.beginPath(); pctx.moveTo(M.l, y); pctx.lineTo(M.l + PW, y); pctx.stroke();
}
pctx.textAlign = 'right';
pctx.fillText(label, M.l - 8, y + 3);
}
pctx.textAlign = 'center';
for (const [hz, label] of TICKS) {
const x = Math.round(xOf(hz)) + 0.5;
pctx.beginPath(); pctx.moveTo(x, M.t); pctx.lineTo(x, M.t + PH); pctx.stroke();
pctx.fillText(label, x, M.t + PH + 16);
}
pctx.textAlign = 'left';
pctx.fillText(formatHz(CAPS.hzMin), M.l, M.t + PH + 16);
pctx.textAlign = 'right';
pctx.fillText(formatHz(CAPS.hzMax), M.l + PW, M.t + PH + 16);
pctx.strokeStyle = T.rule;
pctx.beginPath(); pctx.moveTo(M.l, M.t + PH + 0.5); pctx.lineTo(M.l + PW, M.t + PH + 0.5); pctx.stroke();
// spectrum staircase
const spec = frame.spectrum || [];
if (spec.length > 1) {
pctx.fillStyle = T.inkA(0.14);
pctx.beginPath();
pctx.moveTo(M.l, yOf(spec[0]));
const bw = PW / spec.length;
for (let i = 0; i < spec.length; i++) {
const y = yOf(spec[i]);
pctx.lineTo(M.l + i * bw, y);
pctx.lineTo(M.l + (i + 1) * bw, y);
}
pctx.lineTo(M.l + PW, M.t + PH);
pctx.lineTo(M.l, M.t + PH);
pctx.closePath();
pctx.fill();
}
// boxes
cfg.bands.forEach((band, i) => {
const win = bandWindow(i);
const x0 = xOf(band.hzMin), x1 = xOf(band.hzMax);
const yTop = yOf(win.hi), yBot = yOf(win.lo);
const isSel = i === sel;
const stroke = isSel ? T.led : (band.muted ? T.faint : T.ink);
const alpha = band.muted ? 0.04 : (isSel ? 0.12 : 0.07);
pctx.fillStyle = T.ledA(alpha);
pctx.fillRect(x0, yTop, x1 - x0, yBot - yTop);
pctx.strokeStyle = stroke;
pctx.lineWidth = isSel ? 1.5 : 1;
pctx.beginPath(); pctx.moveTo(x0, yTop); pctx.lineTo(x0, yBot); pctx.stroke();
pctx.beginPath(); pctx.moveTo(x1, yTop); pctx.lineTo(x1, yBot); pctx.stroke();
if (cfg.autoRange) pctx.setLineDash([5, 4]);
pctx.beginPath(); pctx.moveTo(x0, yTop); pctx.lineTo(x1, yTop); pctx.stroke();
pctx.beginPath(); pctx.moveTo(x0, yBot); pctx.lineTo(x1, yBot); pctx.stroke();
pctx.setLineDash([]);
// live level as a horizontal line across the box — the band's average.
// The line BELONGS to the box: below the window it sits pinned on the
// bottom edge ("resting under the floor"), above it on the top. Drawn at
// the raw position it wandered out of its own box, which read as broken.
const level = (frame.levels && frame.levels[i]) || 0;
const ly = Math.max(yTop + 1.5, Math.min(yBot - 1.5, yOf(level)));
pctx.strokeStyle = T.led;
pctx.lineWidth = 2;
pctx.beginPath();
pctx.moveTo(x0 + 1, ly);
pctx.lineTo(x1 - 1, ly);
pctx.stroke();
if (isSel) {
pctx.fillStyle = T.field;
pctx.strokeStyle = T.ink;
pctx.lineWidth = 1;
for (const [cx, cy] of [[x0, yTop], [x1, yTop], [x0, yBot], [x1, yBot]]) {
pctx.fillRect(cx - 3.5, cy - 3.5, 7, 7);
pctx.strokeRect(cx - 3.5, cy - 3.5, 7, 7);
}
}
});
placeTags();
}
function placeTags() {
const T = theme();
cfg.bands.forEach((band, i) => {
const tag = $('tag' + i);
const win = bandWindow(i);
tag.style.left = Math.round(xOf(band.hzMin)) + 'px';
tag.style.top = Math.round(yOf(win.hi) - 11) + 'px';
tag.classList.toggle('sel', i === sel);
tag.classList.toggle('mutedTag', band.muted);
tag.querySelector('.tagText').textContent = 'B' + (i + 1) + (band.muted ? '·M' : '');
drawGlyph(tag.querySelector('canvas'), band, 18, 12, T.ink);
});
}
// tiny curve glyph, used by tags and footer chips
function drawGlyph(canvas, band, w, h, color) {
const dpr = window.devicePixelRatio || 1;
if (canvas.width !== w * dpr) {
canvas.width = w * dpr; canvas.height = h * dpr;
canvas.style.width = w + 'px'; canvas.style.height = h + 'px';
}
const g = canvas.getContext('2d');
g.setTransform(dpr, 0, 0, dpr, 0, 0);
g.clearRect(0, 0, w, h);
g.strokeStyle = color || theme().ink;
g.lineWidth = 1.4;
g.lineCap = 'round';
g.beginPath();
for (let i = 0; i <= 16; i++) {
const u = i / 16;
const v = bandCurveValue(band, u);
const x = 2 + u * (w - 4);
const y = h - 2 - v * (h - 4);
if (i === 0) g.moveTo(x, y); else g.lineTo(x, y);
}
g.stroke();
}
// ── main plot interaction ───────────────────────────────────────────────
const EDGE = 7;
let drag = null;
function hitTest(x, y) {
// Selected band's edges win first — reaching for a handle should not
// reselect the neighbour underneath it.
const order = [sel, ...cfg.bands.map((_, i) => i).filter((i) => i !== sel)];
for (const i of order) {
const band = cfg.bands[i];
const win = bandWindow(i);
const x0 = xOf(band.hzMin), x1 = xOf(band.hzMax);
const yTop = yOf(win.hi), yBot = yOf(win.lo);
const insideX = x > x0 - EDGE && x < x1 + EDGE;
const insideY = y > yTop - EDGE && y < yBot + EDGE;
if (!insideX || !insideY) continue;
const nearL = Math.abs(x - x0) <= EDGE, nearR = Math.abs(x - x1) <= EDGE;
const nearT = Math.abs(y - yTop) <= EDGE, nearB = Math.abs(y - yBot) <= EDGE;
// Corners first — a grab there resizes both axes at once.
if (nearL && nearT) return { band: i, mode: 'tl' };
if (nearR && nearT) return { band: i, mode: 'tr' };
if (nearL && nearB) return { band: i, mode: 'bl' };
if (nearR && nearB) return { band: i, mode: 'br' };
if (nearL) return { band: i, mode: 'l' };
if (nearR) return { band: i, mode: 'r' };
if (nearT) return { band: i, mode: 't' };
if (nearB) return { band: i, mode: 'b' };
if (x > x0 && x < x1 && y > yTop && y < yBot) return { band: i, mode: 'move' };
}
return null;
}
// Grabbing a breathing edge is the gesture that says "I'll take it from
// here": auto range switches off, and every band's window is seeded from the
// envelope it was just breathing at, so nothing jumps.
function freezeAutoWindows() {
const env = (frame.env && frame.env.length ? frame.env : lastEnv) || [];
cfg.bands.forEach((band, i) => {
const e = env[i];
if (!e) return;
band.inMin = clamp01(e.lo);
band.inMax = Math.max(band.inMin + 0.02, clamp01(e.hi));
});
cfg.autoRange = false;
syncAutoToggle();
}
function plotPointer(event) {
const rect = plotCanvas.getBoundingClientRect();
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
}
plotCanvas.addEventListener('pointerdown', (event) => {
const { x, y } = plotPointer(event);
const hit = hitTest(x, y);
if (!hit) return;
if (hit.band !== sel) selectBand(hit.band);
if (cfg.autoRange && hit.mode !== 'move' && hit.mode !== 'l' && hit.mode !== 'r') {
freezeAutoWindows(); // any grab that touches a breathing edge takes over
}
const band = cfg.bands[hit.band];
const win = bandWindow(hit.band);
drag = {
...hit,
startX: x, startY: y,
hzMin: band.hzMin, hzMax: band.hzMax,
lo: win.lo, hi: win.hi
};
plotCanvas.setPointerCapture(event.pointerId);
event.preventDefault();
});
plotCanvas.addEventListener('pointermove', (event) => {
const { x, y } = plotPointer(event);
if (!drag) {
const hit = hitTest(x, y);
plotCanvas.style.cursor = !hit ? 'default'
: hit.mode === 'move' ? 'move'
: (hit.mode === 'tl' || hit.mode === 'br') ? 'nwse-resize'
: (hit.mode === 'tr' || hit.mode === 'bl') ? 'nesw-resize'
: (hit.mode === 'l' || hit.mode === 'r') ? 'ew-resize' : 'ns-resize';
return;
}
const band = cfg.bands[drag.band];
const MINR = 1.12; // narrowest box, as a frequency ratio
if (drag.mode === 'move') {
// Translate in log-frequency: the box keeps its RATIO width, which is
// what a log axis means by "same size somewhere else".
const shift = (x - drag.startX) / PW * DECADES;
let ratio = Math.pow(10, shift);
ratio = Math.max(CAPS.hzMin / drag.hzMin, Math.min(CAPS.hzMax / drag.hzMax, ratio));
band.hzMin = drag.hzMin * ratio;
band.hzMax = drag.hzMax * ratio;
if (!cfg.autoRange) {
const dv = vOf(y) - vOf(drag.startY);
const span = drag.hi - drag.lo;
const lo = Math.max(0, Math.min(1 - span, drag.lo + dv));
band.inMin = lo;
band.inMax = lo + span;
}
} else {
// Edges and corners share the same four moves; a corner just does two.
if (drag.mode.includes('l')) band.hzMin = Math.min(hzOf(x), band.hzMax / MINR);
if (drag.mode.includes('r')) band.hzMax = Math.max(hzOf(x), band.hzMin * MINR);
if (drag.mode.includes('t')) band.inMax = Math.max(vOf(y), band.inMin + 0.02);
if (drag.mode.includes('b')) band.inMin = Math.min(vOf(y), band.inMax - 0.02);
}
drawPlot();
renderChips();
renderSettings();
});
const releasePlot = () => {
if (!drag) return;
drag = null;
persist();
};
plotCanvas.addEventListener('pointerup', releasePlot);
plotCanvas.addEventListener('pointercancel', releasePlot);
// ── curve panel ─────────────────────────────────────────────────────────
const curveCanvas = $('curve');
const cctx = curveCanvas.getContext('2d');
const CM = { l: 30, r: 10, t: 12, b: 26 };
let CW = 0, CH = 0;
let curveDrag = null;
function sizeCurve() {
const dpr = window.devicePixelRatio || 1;
const cssW = 356, cssH = 218;
curveCanvas.width = cssW * dpr; curveCanvas.height = cssH * dpr;
curveCanvas.style.width = cssW + 'px'; curveCanvas.style.height = cssH + 'px';
cctx.setTransform(dpr, 0, 0, dpr, 0, 0);
CW = cssW - CM.l - CM.r; CH = cssH - CM.t - CM.b;
}
const cxOf = (u) => CM.l + clamp01(u) * CW;
const cyOf = (v) => CM.t + (1 - clamp01(v)) * CH;
function drawCurve() {
const T = theme();
const band = cfg.bands[sel];
const W = CM.l + CW + CM.r, H = CM.t + CH + CM.b;
cctx.clearRect(0, 0, W, H);
cctx.fillStyle = T.cream;
cctx.fillRect(CM.l, CM.t, CW, CH);
cctx.strokeStyle = T.rule;
cctx.lineWidth = 1;
cctx.strokeRect(CM.l + 0.5, CM.t + 0.5, CW - 1, CH - 1);
cctx.strokeStyle = T.ruleA(0.5);
cctx.beginPath(); cctx.moveTo(cxOf(0.5), CM.t); cctx.lineTo(cxOf(0.5), CM.t + CH); cctx.stroke();
cctx.beginPath(); cctx.moveTo(CM.l, cyOf(0.5)); cctx.lineTo(CM.l + CW, cyOf(0.5)); cctx.stroke();
// curve
cctx.strokeStyle = T.ink;
cctx.lineWidth = 2;
cctx.lineCap = 'round';
cctx.beginPath();
const steps = band.curve && band.curve.type === 'steps';
const N = steps ? 200 : 64;
for (let i = 0; i <= N; i++) {
const u = i / N;
const v = bandCurveValue(band, u);
const x = cxOf(u), y = cyOf(v);
if (i === 0) cctx.moveTo(x, y); else cctx.lineTo(x, y);
}
cctx.stroke();
// bezier handles
if (band.curve && band.curve.type === 'bezier') {
const c = band.curve;
cctx.strokeStyle = T.faint;
cctx.setLineDash([2, 3]);
cctx.beginPath(); cctx.moveTo(cxOf(0), cyOf(c.y0)); cctx.lineTo(cxOf(c.p1x), cyOf(c.p1y)); cctx.stroke();
cctx.beginPath(); cctx.moveTo(cxOf(1), cyOf(c.y1)); cctx.lineTo(cxOf(c.p2x), cyOf(c.p2y)); cctx.stroke();
cctx.setLineDash([]);
cctx.fillStyle = T.led;
cctx.strokeStyle = T.ink;
cctx.lineWidth = 1;
for (const [hx, hy] of [[c.p1x, c.p1y], [c.p2x, c.p2y]]) {
cctx.fillRect(cxOf(hx) - 3.5, cyOf(hy) - 3.5, 7, 7);
cctx.strokeRect(cxOf(hx) - 3.5, cyOf(hy) - 3.5, 7, 7);
}
}
// live cursor: where the music is on this curve right now
const win = bandWindow(sel);
const level = (frame.levels && frame.levels[sel]) || 0;
const u = clamp01((level - win.lo) / Math.max(0.001, win.hi - win.lo));
const v = bandCurveValue(band, u);
cctx.strokeStyle = T.led;
cctx.setLineDash([3, 3]);
cctx.lineWidth = 1;
cctx.beginPath(); cctx.moveTo(cxOf(u), CM.t + CH); cctx.lineTo(cxOf(u), cyOf(v)); cctx.stroke();
cctx.setLineDash([]);
cctx.fillStyle = T.led;
cctx.beginPath(); cctx.arc(cxOf(u), cyOf(v), 4.5, 0, Math.PI * 2); cctx.fill();
// labels
cctx.font = MONO;
cctx.fillStyle = T.faint;
cctx.textAlign = 'left';
cctx.fillText(cfg.autoRange ? 'in auto' : 'in ' + win.lo.toFixed(2), CM.l, CM.t + CH + 16);
cctx.textAlign = 'right';
cctx.fillText(cfg.autoRange ? '' : win.hi.toFixed(2), CM.l + CW, CM.t + CH + 16);
cctx.fillText(cfg.bands[sel].outMax.toFixed(2), CM.l - 6, CM.t + 8);
cctx.fillText(cfg.bands[sel].outMin.toFixed(2), CM.l - 6, CM.t + CH);
cctx.textAlign = 'left';
cctx.fillText(u.toFixed(2) + ' → ' + (cfg.bands[sel].outMin + v * (cfg.bands[sel].outMax - cfg.bands[sel].outMin)).toFixed(2), CM.l + 6, CM.t + 12);
}
curveCanvas.addEventListener('pointerdown', (event) => {
const band = cfg.bands[sel];
if (!band.curve || band.curve.type !== 'bezier') return;
const rect = curveCanvas.getBoundingClientRect();
const x = event.clientX - rect.left, y = event.clientY - rect.top;
const c = band.curve;
const near = (hx, hy) => Math.hypot(x - cxOf(hx), y - cyOf(hy)) < 12;
if (near(c.p1x, c.p1y)) curveDrag = 'p1';
else if (near(c.p2x, c.p2y)) curveDrag = 'p2';
else return;
curveCanvas.setPointerCapture(event.pointerId);
event.preventDefault();
});
curveCanvas.addEventListener('pointermove', (event) => {
if (!curveDrag) return;
const rect = curveCanvas.getBoundingClientRect();
const u = clamp01((event.clientX - rect.left - CM.l) / CW);
const v = clamp01(1 - (event.clientY - rect.top - CM.t) / CH);
const band = cfg.bands[sel];
const c = band.curve;
if (curveDrag === 'p1') { c.p1x = u; c.p1y = v; }
else { c.p2x = u; c.p2y = v; }
c.id = 'custom';
band.lut = bakeLut(c);
drawCurve();
renderPresetChips();
placeTags();
});
const releaseCurve = () => {
if (!curveDrag) return;
curveDrag = null;
persist();
renderChips();
};
curveCanvas.addEventListener('pointerup', releaseCurve);
curveCanvas.addEventListener('pointercancel', releaseCurve);
// ── presets, steps, settings column ─────────────────────────────────────
function renderPresetChips() {
const band = cfg.bands[sel];
const id = band.curve ? (band.curve.id || 'custom') : null;
document.querySelectorAll('.preset').forEach((el) => {
el.classList.toggle('on', el.dataset.p === id);
});
$('legacyHint').hidden = !!band.curve;
$('stepsRow').hidden = !(band.curve && band.curve.type === 'steps');
if (band.curve && band.curve.type === 'steps') $('stepsN').textContent = String(band.curve.n);
document.querySelectorAll('.preset canvas').forEach((canvas) => {
const preset = PRESETS[canvas.parentElement.dataset.p];
drawGlyph(canvas, { curve: preset.curve, gain: 1 }, 40, 24, theme().ink);
});
}
document.querySelectorAll('.preset').forEach((el) => {
el.addEventListener('click', () => {
const preset = PRESETS[el.dataset.p];
if (!preset) return;
touchCurve(cfg.bands[sel], structuredClone(preset.curve));
drawCurve();
renderPresetChips();
placeTags();
renderChips();
persist();
});
});
$('stepsMinus').addEventListener('click', () => stepsAdjust(-1));
$('stepsPlus').addEventListener('click', () => stepsAdjust(1));
function stepsAdjust(delta) {
const band = cfg.bands[sel];
if (!band.curve || band.curve.type !== 'steps') return;
band.curve.n = Math.max(2, Math.min(8, band.curve.n + delta));
band.curve.id = band.curve.n === 2 ? 'gate' : 'steps';
band.lut = bakeLut(band.curve);
drawCurve();
renderPresetChips();
placeTags();
renderChips();
persist();
}
function renderSettings() {
const band = cfg.bands[sel];
$('bandName').textContent = 'Band ' + (sel + 1);
$('bandMeta').textContent = formatHz(band.hzMin) + ' – ' + formatHz(band.hzMax) + ' Hz'
+ (cfg.autoRange ? ' · in auto' : ' · in ' + band.inMin.toFixed(2) + '–' + band.inMax.toFixed(2));
$('outLabel').textContent = band.outMin.toFixed(2) + ' – ' + band.outMax.toFixed(2);
const track = $('outTrack');
const w = track.clientWidth - 10;
$('outLo').style.left = (5 + band.outMin * w - 5) + 'px';
$('outHi').style.left = (5 + band.outMax * w - 5) + 'px';
$('outFill').style.left = (5 + band.outMin * w) + 'px';
$('outFill').style.width = Math.max(0, (band.outMax - band.outMin) * w) + 'px';
$('muteToggle').classList.toggle('on', band.muted);
}
document.querySelectorAll('.outPreset').forEach((el) => {
el.addEventListener('click', () => {
const band = cfg.bands[sel];
band.outMin = Number(el.dataset.lo);
band.outMax = Number(el.dataset.hi);
renderSettings(); drawCurve(); renderChips(); persist();
});
});
let outDrag = null;
$('outTrack').addEventListener('pointerdown', (event) => {
const band = cfg.bands[sel];
const rect = $('outTrack').getBoundingClientRect();
const v = clamp01((event.clientX - rect.left - 5) / (rect.width - 10));
outDrag = Math.abs(v - band.outMin) < Math.abs(v - band.outMax) ? 'lo' : 'hi';
$('outTrack').setPointerCapture(event.pointerId);
event.preventDefault();
});
$('outTrack').addEventListener('pointermove', (event) => {
if (!outDrag) return;
const band = cfg.bands[sel];
const rect = $('outTrack').getBoundingClientRect();
const v = clamp01((event.clientX - rect.left - 5) / (rect.width - 10));
if (outDrag === 'lo') band.outMin = Math.min(v, band.outMax - 0.05);
else band.outMax = Math.max(v, band.outMin + 0.05);
renderSettings(); drawCurve();
});
const releaseOut = () => { if (outDrag) { outDrag = null; persist(); } };
$('outTrack').addEventListener('pointerup', releaseOut);
$('outTrack').addEventListener('pointercancel', releaseOut);
$('muteToggle').addEventListener('click', () => {
const band = cfg.bands[sel];
band.muted = !band.muted;
renderSettings(); renderChips(); drawPlot(); persist();
});
$('resetBand').addEventListener('click', () => {
cfg.bands[sel] = defaultBand(sel);
touchCurve(cfg.bands[sel], structuredClone(PRESETS.smooth.curve));
selectBand(sel);
persist();
});
// ── footer chips ────────────────────────────────────────────────────────
function renderChips() {
cfg.bands.forEach((band, i) => {
const chip = $('chip' + i);
chip.classList.toggle('sel', i === sel);
chip.classList.toggle('mutedChip', band.muted);
chip.querySelector('.chipName').textContent = 'B' + (i + 1);
chip.querySelector('.chipHz').textContent = formatHz(band.hzMin) + '–' + formatHz(band.hzMax);
drawGlyph(chip.querySelector('canvas'), band, 18, 12, theme().muted);
});
}
for (let i = 0; i < 4; i++) {
$('chip' + i).addEventListener('click', () => selectBand(i));
}
function selectBand(index) {
sel = Math.max(0, Math.min(3, index));
drawPlot();
drawCurve();
renderPresetChips();
renderSettings();
renderChips();
}
// ── preview ─────────────────────────────────────────────────────────────
//
// Ableton-style: synthetic test signals through the SELECTED band's whole
// chain - window, glide ballistics, curve, output range - drawn as a
// scrolling scope. What the knob will do, audible music not required.
const pvOverlay = $('pvOverlay');
const pvScope = $('pvScope');
const pvCtx = pvScope.getContext('2d');
let pvSig = 'pulse';
let pvRaf = 0;
let pvTimer = 0;
let pvT = 0;
let pvLast = 0;
let pvLevel = 0;
const PV_SECONDS = 4;
const PV_STEP = 1 / 30;
let pvIn = [];
let pvOut = [];
function pvSignal(t) {
if (pvSig === 'pulse') {
// A kick every 0.8 s: one sharp burst, then near-silence. The tail the
// output shows comes from the glide, which is the point of the demo.
const phase = t % 0.8;
const spike = phase < 0.06 ? 0.85 : 0;
return Math.min(1, Math.max(0, spike + 0.07 + (Math.random() - 0.5) * 0.03));
}
if (pvSig === 'noisy') {
const spike = (t % 1.7) < 0.06 ? 0.5 : 0;
return Math.min(1, Math.max(0,
0.30 + 0.10 * Math.sin(t * 1.3) + (Math.random() - 0.5) * 0.22 + spike));
}
// swell
return Math.min(1, Math.max(0,
0.45 + 0.35 * Math.sin(t * Math.PI * 2 * 0.35) + (Math.random() - 0.5) * 0.04));
}
function pvChain(sig) {
// Mirror the live pipeline: asymmetric glide on the LEVEL, then window,
// curve, output range.
const a = pvLevel < sig
? Math.max(0.05, Math.min(0.9, cfg.attack))
: Math.max(0.05, Math.min(0.9, cfg.smoothing));
pvLevel += (sig - pvLevel) * a;
const band = cfg.bands[sel];
let u;
if (cfg.autoRange) {
u = clamp01((pvLevel - 0.10) / 0.85);
} else {
const inMin = clamp01(band.inMin);
const inMax = Math.max(inMin + 0.01, clamp01(band.inMax));
u = clamp01((pvLevel - inMin) / (inMax - inMin));
}
return band.outMin + bandCurveValue(band, u) * (band.outMax - band.outMin);
}
function pvDraw() {
const T = theme();
const w = 600, h = 230;
pvCtx.clearRect(0, 0, w, h);
pvCtx.strokeStyle = T.ruleA(0.5);
pvCtx.lineWidth = 1;
for (const v of [0.25, 0.5, 0.75]) {
const y = Math.round(h - v * h) + 0.5;
pvCtx.beginPath(); pvCtx.moveTo(0, y); pvCtx.lineTo(w, y); pvCtx.stroke();
}
const trace = (arr, color, width) => {
pvCtx.strokeStyle = color;
pvCtx.lineWidth = width;
pvCtx.beginPath();
for (let i = 0; i < arr.length; i++) {
const x = (i / (PV_SECONDS / PV_STEP)) * w;
const y = h - clamp01(arr[i]) * h;
if (i === 0) pvCtx.moveTo(x, y); else pvCtx.lineTo(x, y);
}
pvCtx.stroke();
};
trace(pvIn, T.faint, 1.2);
trace(pvOut, T.led, 2);
}
function pvTick(now) {
if (pvOverlay.hidden) return;
if (!pvLast) pvLast = now;
// Wall-clock scheduling, not per-frame increments: a throttled tab still
// simulates the RIGHT amount of time when frames do arrive (capped per
// frame so a background stint cannot demand thousands of steps at once).
const target = pvT + (now - pvLast) / 1000;
pvLast = now;
let budget = 60;
while (pvT < target && budget-- > 0) {
pvT += PV_STEP;
const sig = pvSignal(pvT);
pvIn.push(sig);
pvOut.push(pvChain(sig));
const cap = PV_SECONDS / PV_STEP;
if (pvIn.length > cap) { pvIn.shift(); pvOut.shift(); }
}
if (budget < 0) pvT = target;
pvDraw();
pvSchedule();
}
// rAF when the surface is live, a timer backstop when it is throttled or
// hidden - the preview keeps moving either way.
function pvSchedule() {
pvRaf = requestAnimationFrame(pvTick);
clearTimeout(pvTimer);
pvTimer = setTimeout(() => {
cancelAnimationFrame(pvRaf);
pvTick(performance.now());
}, 300);
}
function pvOpen() {
const dpr = window.devicePixelRatio || 1;
pvScope.width = 600 * dpr; pvScope.height = 230 * dpr;
pvScope.style.width = '600px'; pvScope.style.height = '230px';
pvCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
$('pvTitle').textContent = 'Preview — Band ' + (sel + 1);
pvIn = []; pvOut = []; pvT = 0; pvLast = 0; pvLevel = 0;
pvOverlay.hidden = false;
pvSchedule();
}
function pvClose() {
pvOverlay.hidden = true;
cancelAnimationFrame(pvRaf);
clearTimeout(pvTimer);
}
$('openPreview').addEventListener('click', pvOpen);
$('pvClose').addEventListener('click', pvClose);
pvOverlay.addEventListener('click', (e) => { if (e.target === pvOverlay) pvClose(); });
document.querySelectorAll('.pvSig').forEach((el) => {
el.addEventListener('click', () => {
pvSig = el.dataset.s;
document.querySelectorAll('.pvSig').forEach((x) => x.classList.toggle('on', x === el));
});
});
// ── auto toggle, status, wiring ─────────────────────────────────────────
function syncAutoToggle() {
$('autoToggle').classList.toggle('on', cfg.autoRange);
$('autoHint').textContent = cfg.autoRange
? 'boxes breathe with the room — drag a top or bottom edge to take manual control'
: 'manual windows — the box edges are exactly what maps';
}
$('autoToggle').addEventListener('click', () => {
if (cfg.autoRange) freezeAutoWindows();
else cfg.autoRange = true;
syncAutoToggle();
drawPlot(); drawCurve(); renderSettings();
persist();
});
$('stopBtn').addEventListener('click', () => A.stop());
// Damping = the level smoothing the analysis already runs (an EMA on each
// band, before the window and curve). Right means calmer: the slider's 0..1
// maps to alpha 0.9..0.05, so a gentle word scale replaces a number nobody
// would know the direction of. It smooths the INPUT level, not the output —
// gate and steps curves stay crisp, their trigger just stops flickering.
const DAMP_WORDS = [[0.25, 'tight'], [0.5, 'balanced'], [0.75, 'smooth'], [1.01, 'glassy']];
const ATK_WORDS = [[0.25, 'snap'], [0.5, 'quick'], [0.75, 'soft'], [1.01, 'lazy']];
function dampWord(v) {
for (const [top, word] of DAMP_WORDS) if (v < top) return word;
return 'glassy';
}
function syncDamping() {
const v = clamp01((0.9 - cfg.smoothing) / 0.85);
$('damping').value = String(v);
$('dampingWord').textContent = dampWord(v);
const k = clamp01((0.9 - cfg.attack) / 0.85);
$('attack').value = String(k);
$('attackWord').textContent = ATK_WORDS.find(([top]) => k < top)[1];
}
$('attack').addEventListener('input', () => {
const v = clamp01($('attack').value);
cfg.attack = Math.max(0.05, Math.min(0.9, 0.9 - v * 0.85));
$('attackWord').textContent = ATK_WORDS.find(([top]) => v < top)[1];
persist();
});
$('damping').addEventListener('input', () => {
const v = clamp01($('damping').value);
cfg.smoothing = Math.max(0.05, Math.min(0.9, 0.9 - v * 0.85));
$('dampingWord').textContent = dampWord(v);
persist();
});
function renderStatus() {
const el = $('sourceChip');
if (frame.demo) { el.textContent = 'demo source'; el.className = 'chip'; }
else if (frame.running && frame.connected) { el.textContent = (A.labels && A.labels.live) || 'live · tab audio'; el.className = 'chip okChip'; }
else if (frame.running) { el.textContent = 'capturing · connecting'; el.className = 'chip'; }
else { el.textContent = 'idle'; el.className = 'chip'; }
$('hostChip').textContent = cfg.host + ':81';
$('captureHint').textContent = frame.running || frame.demo ? '' : A.captureHint;
$('stopBtn').disabled = !frame.running;
}
A.onFrame((state) => {
frame = state || {};
if (frame.env && frame.env.length) lastEnv = frame.env;
drawPlot();
drawCurve();
renderStatus();
});
window.addEventListener('resize', () => {
sizePlot();
drawPlot();
renderSettings();
});
(async function init() {
const stored = await A.loadConfig();
cfg = normalizeConfig(stored);
if (A._setDemoConfig) A._setDemoConfig(cfg);
sizePlot();
sizeCurve();
syncAutoToggle();
syncDamping();
selectBand(0);
renderStatus();
A.requestStatus();
})();