Loading files
98b423c28This file is pinned to the selected release. Download the original to open it in your design tool.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<script src="/pf-console.js"></script>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Patternflow - Mic mapping</title>
<!-- GENERATED by firmware/toolchain/build_audio_in_page.py from
tools/patternflow-audio-extension (editor 290e552456).
Edit the editor there, rerun the builder, then console_pages.py build. -->
<style>
/* Patternflow Audio — mapping editor. Same cream instrument palette as the
popup (popup.css is the source of these values). */
:root {
--cream: #f4efe6;
--cream-2: #ede7db;
--ink: #141414;
--muted: #6b655a;
--faint: #a69f90;
--rule: #d9d1c0;
--led: #e8552e;
--ok: #267a43;
--bad: #b43b2d;
--field: #fbf7ef;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--cream);
color: var(--ink);
font: 12px/1.45 Inter, ui-sans-serif, system-ui, sans-serif;
}
.page {
max-width: 1280px;
margin: 0 auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.mono { font-family: var(--mono); }
.dimText { color: var(--muted); }
.hint { font-size: 11px; color: var(--faint); }
.spacer { flex-grow: 1; }
/* ── header ── */
header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.brand { display: flex; align-items: center; gap: 9px; }
.led { width: 8px; height: 8px; background: var(--led); border-radius: 1px; }
.title { font-size: 14px; font-weight: 600; }
.kicker { font-family: var(--mono); font-size: 10px; letter-spacing: 0.09em; color: var(--muted); }
.cluster { display: flex; align-items: center; gap: 8px; }
.chip {
border: 1px solid var(--rule); border-radius: 2px; background: var(--field);
padding: 5px 10px; font-family: var(--mono); font-size: 11px; color: var(--ink);
}
.chip.dim { color: var(--muted); }
.okChip { border-color: var(--ok); color: var(--ok); }
.primaryBtn {
font: inherit; font-size: 12px; cursor: pointer;
border: 1px solid var(--ink); border-radius: 2px;
background: var(--ink); color: var(--cream); padding: 6px 14px;
}
.primaryBtn:disabled { opacity: 0.35; cursor: default; }
.ghostBtn {
font: inherit; font-size: 11px; cursor: pointer;
border: 1px solid var(--rule); border-radius: 2px;
background: var(--field); color: var(--muted); padding: 6px 12px;
}
.ghostBtn:hover { border-color: var(--led); color: var(--led); }
/* ── toolbar / toggles ── */
.toolbar { display: flex; align-items: center; gap: 10px; }
.damping { display: flex; align-items: center; gap: 8px; }
.damping input { width: 130px; accent-color: var(--led); }
.damping .mono { font-size: 11px; min-width: 56px; text-align: right; }
.toggleWrap {
font: inherit; cursor: pointer; display: flex; align-items: center; gap: 7px;
border: 1px solid var(--ink); border-radius: 2px; background: var(--field); padding: 5px 11px;
}
.toggleLabel { font-size: 11px; font-weight: 500; }
.toggle {
width: 22px; height: 12px; background: var(--rule); border-radius: 7px;
position: relative; display: inline-block; border: 0; cursor: pointer; padding: 0;
flex: none;
}
.toggle .dotK, .toggleWrap .dotK {
position: absolute; left: 1px; top: 1px; width: 10px; height: 10px;
background: var(--field); border-radius: 50%; transition: left 0.12s;
}
.toggleWrap .toggle { pointer-events: none; }
.toggleWrap.on .toggle { background: var(--ok); }
.toggleWrap.on .dotK { left: 11px; }
.toggle.small.on { background: var(--ok); }
.toggle.small.on .dotK { left: 11px; }
/* ── main plot ── */
.plotWrap {
position: relative; background: var(--field);
border: 1px solid var(--rule); border-radius: 2px;
}
#plot { display: block; cursor: default; touch-action: none; }
.tag {
position: absolute; display: flex; align-items: center; gap: 6px;
background: var(--cream-2); border: 1px solid var(--rule); border-radius: 2px;
padding: 2px 7px; pointer-events: none;
}
.tag.sel { border-color: var(--led); }
.tag.mutedTag { opacity: 0.55; }
.tagText { font-family: var(--mono); font-size: 10px; }
/* ── curve panel ── */
.panel {
display: flex; gap: 20px; background: var(--field);
border: 1px solid var(--rule); border-radius: 2px; padding: 16px 18px;
}
.col { display: flex; flex-direction: column; gap: 8px; }
.col.grow { flex-grow: 1; gap: 12px; }
.colKicker { font-family: var(--mono); font-size: 10px; letter-spacing: 0.09em; color: var(--muted); }
.vr { width: 1px; background: var(--rule); }
#curve { display: block; touch-action: none; }
.presetsCol { width: 268px; }
.presetGrid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.preset {
font: inherit; cursor: pointer;
display: flex; flex-direction: column; align-items: center; gap: 5px;
border: 1px solid var(--rule); border-radius: 2px; background: var(--cream);
padding: 9px 4px 7px;
}
.preset span { font-size: 10px; font-weight: 500; color: var(--ink); }
.preset.on { border-color: var(--led); }
.preset.on span { font-weight: 600; }
.stepsRow { display: flex; align-items: center; gap: 7px; font-size: 11px; color: var(--muted); }
.stepsRow[hidden] { display: none; }
.stepsRow button {
font: inherit; cursor: pointer; width: 18px; height: 18px; padding: 0; line-height: 1;
border: 1px solid var(--rule); border-radius: 2px; background: var(--cream); color: var(--ink);
}
/* ── settings column ── */
.bandHead { display: flex; align-items: baseline; gap: 8px; }
.bandName { font-size: 13px; font-weight: 600; white-space: nowrap; }
.field { display: flex; flex-direction: column; gap: 6px; }
.fieldRow { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.fieldLabel { font-size: 11px; color: var(--muted); }
.seg { display: flex; }
.knobBtn {
font: inherit; font-family: var(--mono); font-size: 11px; cursor: pointer;
border: 1px solid var(--rule); background: var(--cream); color: var(--muted);
padding: 6px 14px; border-left-width: 0;
}
.knobBtn:first-child { border-left-width: 1px; border-radius: 2px 0 0 2px; }
.knobBtn:last-child { border-radius: 0 2px 2px 0; }
.knobBtn.on { border-color: var(--ink); background: var(--ink); color: var(--cream); }
.outTrack { position: relative; height: 18px; cursor: pointer; touch-action: none; }
.outRail { position: absolute; left: 4px; right: 4px; top: 8px; height: 2px; background: var(--rule); }
.outFill { position: absolute; top: 7.5px; height: 3px; background: var(--led); }
.outHandle {
position: absolute; top: 4px; width: 9px; height: 10px;
background: var(--field); border: 1px solid var(--ink);
}
/* ── footer chips ── */
.chips { display: flex; align-items: center; gap: 8px; }
.chipCard {
font: inherit; cursor: pointer;
display: flex; align-items: center; gap: 8px;
background: var(--field); border: 1px solid var(--rule); border-radius: 2px;
padding: 7px 12px;
}
.chipCard.sel { border-color: var(--led); }
.chipCard.mutedChip { opacity: 0.55; }
.chipDot { width: 6px; height: 6px; background: var(--led); border-radius: 1px; }
.chipCard .mono { font-size: 11px; }
.arrow { font-size: 10px; color: var(--faint); }
/* ── output presets + preview ── */
.outPresets { display: flex; gap: 6px; }
.outPreset {
font: inherit; font-size: 10px; cursor: pointer;
border: 1px solid var(--rule); border-radius: 2px;
background: var(--cream); color: var(--muted); padding: 3px 8px;
}
.outPreset:hover { border-color: var(--led); color: var(--led); }
.pvOverlay {
position: fixed; inset: 0; z-index: 50;
background: rgba(20, 20, 20, 0.45);
display: flex; align-items: center; justify-content: center;
}
.pvOverlay[hidden] { display: none; }
.pvCard {
background: var(--cream); border: 1px solid var(--ink); border-radius: 3px;
padding: 18px; display: flex; flex-direction: column; gap: 12px;
width: 640px; max-width: 94vw;
}
.pvHead { display: flex; justify-content: space-between; align-items: center; }
.pvChips { display: flex; gap: 8px; }
.pvSig {
font: inherit; font-size: 11px; cursor: pointer;
border: 1px solid var(--rule); border-radius: 2px;
background: var(--field); color: var(--ink); padding: 5px 11px;
}
.pvSig.on { border-color: var(--led); font-weight: 600; }
#pvScope { display: block; background: var(--field); border: 1px solid var(--rule); }
.pvLegend { display: flex; gap: 7px; align-items: center; }
.pvKeyIn, .pvKeyOut { width: 16px; height: 3px; display: inline-block; }
.pvKeyIn { background: var(--faint); }
.pvKeyOut { background: var(--led); }
/* ── console skin over the editor's cream tokens ── */
:root {
--cream: #0C0B09;
--cream-2: #1B1914;
--ink: #EDE7DB;
--muted: #8A8272;
--faint: #5A5546;
--rule: #242118;
--led: #FF5C2E;
--ok: #57B87F;
--bad: #FF6B5A;
--field: #131110;
}
/* The console's Light toggle stamps html[data-theme=light] and overrides the
CONSOLE-named variables (theme_index.h). The editor's two names of its own
need light values here or the plot field and tags stay dark in a light
page - which read as "the toggle does nothing". Canvas colors follow free:
editor.js reads these variables fresh on every paint. */
html[data-theme=light] {
--cream-2: #E8E2D6;
--field: #FFFCFA;
}
/* device host chip is meaningless when the page IS the device */
#hostChip { display: none; }
.deviceBar {
display: flex; align-items: center; gap: 14px;
background: var(--field); border: 1px solid var(--rule);
border-radius: 2px; padding: 10px 14px;
}
.toggleWrap.on .toggle { background: var(--ok); }
</style>
</head>
<body>
<div class="page">
<header>
<div class="brand">
<span class="led"></span>
<span class="title">Patternflow Audio</span>
<span class="kicker">MAPPING</span>
</div>
<div class="cluster">
<span id="captureHint" class="hint"></span>
<span id="sourceChip" class="chip">idle</span>
<span id="hostChip" class="chip dim">patternflow.local:81</span>
<button id="stopBtn" class="primaryBtn" disabled>Stop</button>
</div>
</header>
<div class="deviceBar">
<button id="audToggle" class="toggleWrap" type="button" title="Enable browser tab and extension audio reaction (AUD)">
<span class="toggle"><span class="dotK"></span></span>
<span class="toggleLabel">Audio-React (AUD)</span>
</button>
<button id="micToggle" class="toggleWrap" type="button" title="Enable on-board microphone">
<span class="toggle"><span class="dotK"></span></span>
<span class="toggleLabel">Microphone</span>
</button>
<div class="damping">
<span class="fieldLabel">Input gain</span>
<input id="micGain" type="range" min="1" max="16" step="0.5">
<span id="micGainVal" class="mono dimText">8.0</span>
</div>
<span id="deviceNote" class="hint"></span>
<span class="spacer"></span>
<a id="extGuideLink" class="ghostBtn" href="https://github.com/engmung/Patternflow/tree/main/tools/patternflow-audio-extension#readme" target="_blank" rel="noopener" style="text-decoration:none;display:inline-flex;align-items:center;gap:4px" title="Install Chrome Audio Extension">Extension ↗</a>
<button id="resetAll" class="ghostBtn" type="button">Reset mapping</button>
</div>
<div class="toolbar">
<button id="autoToggle" class="toggleWrap" type="button">
<span class="toggle"><span class="dotK"></span></span>
<span class="toggleLabel">Auto range</span>
</button>
<span id="autoHint" class="hint"></span>
<span class="spacer"></span>
<div class="damping">
<span class="fieldLabel">Attack</span>
<input id="attack" type="range" min="0" max="1" step="0.01">
<span id="attackWord" class="mono dimText">quick</span>
</div>
<div class="damping">
<span class="fieldLabel">Damping</span>
<input id="damping" type="range" min="0" max="1" step="0.01">
<span id="dampingWord" class="mono dimText">balanced</span>
</div>
<button id="openPreview" class="ghostBtn" type="button"
title="Synthetic test signals through the selected band - see attack, damping and the curve move a knob">Preview</button>
</div>
<div id="plotWrap" class="plotWrap">
<canvas id="plot"></canvas>
<div class="tag" id="tag0"><span class="tagText">B1·K1</span><canvas></canvas></div>
<div class="tag" id="tag1"><span class="tagText">B2·K2</span><canvas></canvas></div>
<div class="tag" id="tag2"><span class="tagText">B3·K3</span><canvas></canvas></div>
<div class="tag" id="tag3"><span class="tagText">B4·K4</span><canvas></canvas></div>
</div>
<div class="panel">
<div class="col">
<span class="colKicker">RESPONSE</span>
<canvas id="curve"></canvas>
</div>
<div class="col presetsCol">
<span class="colKicker">CURVE</span>
<div class="presetGrid">
<button class="preset" data-p="smooth" type="button"><canvas></canvas><span>Smooth</span></button>
<button class="preset" data-p="sharp" type="button"><canvas></canvas><span>Sharp</span></button>
<button class="preset" data-p="fall" type="button"><canvas></canvas><span>Fall</span></button>
<button class="preset" data-p="gate" type="button"><canvas></canvas><span>Gate</span></button>
<button class="preset" data-p="steps" type="button"><canvas></canvas><span>Steps</span></button>
<button class="preset" data-p="arch" type="button"><canvas></canvas><span>Arch</span></button>
</div>
<div id="stepsRow" class="stepsRow" hidden>
<span>steps</span>
<button id="stepsMinus" type="button">−</button>
<span id="stepsN" class="mono">3</span>
<button id="stepsPlus" type="button">+</button>
</div>
<span id="legacyHint" class="hint">This band still runs its old boost curve — pick a preset to upgrade it.</span>
<span class="hint">Drag the two handles for a custom shape. Any curve saves as a 33-point table.</span>
</div>
<div class="vr"></div>
<div class="col grow">
<div class="bandHead">
<span id="bandName" class="bandName">Band 1</span>
<span id="bandMeta" class="mono dimText">62 – 375 Hz</span>
</div>
<div class="field">
<div class="fieldRow">
<span class="fieldLabel">Output range</span>
<span id="outLabel" class="mono">0.30 – 0.85</span>
</div>
<div id="outTrack" class="outTrack">
<div class="outRail"></div>
<div id="outFill" class="outFill"></div>
<div id="outLo" class="outHandle"></div>
<div id="outHi" class="outHandle"></div>
</div>
<div class="outPresets">
<button class="outPreset" type="button" data-lo="0" data-hi="1">0–1</button>
<button class="outPreset" type="button" data-lo="0" data-hi="0.5">0–.5</button>
<button class="outPreset" type="button" data-lo="0.5" data-hi="1">.5–1</button>
<button class="outPreset" type="button" data-lo="0.25" data-hi="0.75">.25–.75</button>
<button class="outPreset" type="button" data-lo="0.15" data-hi="0.6">.15–.6</button>
<button class="outPreset" type="button" data-lo="0.3" data-hi="0.85">.3–.85</button>
</div>
</div>
<div class="fieldRow">
<span class="fieldLabel">Mute band</span>
<button id="muteToggle" class="toggle small" type="button"><span class="dotK"></span></button>
</div>
<div class="spacer"></div>
<div class="fieldRow">
<button id="resetBand" class="ghostBtn" type="button">Reset band</button>
</div>
</div>
</div>
<!-- Preview: synthetic test signals through the SELECTED band's window,
curve and glide - what the knob will do, before any music plays. -->
<div id="pvOverlay" class="pvOverlay" hidden>
<div class="pvCard">
<div class="pvHead">
<span id="pvTitle" class="bandName">Preview — Band 1</span>
<button id="pvClose" class="ghostBtn" type="button">Close</button>
</div>
<div class="pvChips">
<button class="pvSig on" type="button" data-s="pulse">Kick pulses</button>
<button class="pvSig" type="button" data-s="noisy">Noisy room</button>
<button class="pvSig" type="button" data-s="swell">Slow swell</button>
</div>
<canvas id="pvScope"></canvas>
<div class="pvLegend">
<span class="pvKeyIn"></span><span class="hint">input level</span>
<span class="pvKeyOut"></span><span class="hint">knob output</span>
</div>
</div>
</div>
<div class="chips">
<button class="chipCard" id="chip0" type="button"><span class="chipDot"></span><span class="chipName mono">B1</span><span class="chipHz mono dimText">62–375</span><canvas></canvas></button>
<button class="chipCard" id="chip1" type="button"><span class="chipDot"></span><span class="chipName mono">B2</span><span class="chipHz mono dimText">250–2k</span><canvas></canvas></button>
<button class="chipCard" id="chip2" type="button"><span class="chipDot"></span><span class="chipName mono">B3</span><span class="chipHz mono dimText">2k–5k</span><canvas></canvas></button>
<button class="chipCard" id="chip3" type="button"><span class="chipDot"></span><span class="chipName mono">B4</span><span class="chipHz mono dimText">5k–16k</span><canvas></canvas></button>
</div>
</div>
<script>
// Console adapter: the same surface editor-adapter.js gives the extension,
// spoken over fetch('/api/audio-in'). Two extra jobs live here:
//
// - scale conversion. The firmware measures, gates and maps in LINEAR
// amplitude (every constant in core_audio_in_map.h was measured on that
// scale and stays); the editor's vertical axis is dB-normalized so boxes
// drag like hearing works. This file converts both ways at the boundary.
// DB_FLOOR/DB_SPAN are the calibration: quiet room should sit ~0.15 up
// the axis, listening-volume peaks ~0.9. Tweak here, no reflash.
//
// - the device bar. Microphone power and input gain exist only on this
// side; they talk to the same API directly.
(function () {
var DB_FLOOR = -45, DB_SPAN = 47;
function clamp01(v) { v = Number(v) || 0; return v < 0 ? 0 : v > 1 ? 1 : v; }
function dbn(x) { return clamp01((20 * Math.log10(Math.max(Number(x) || 0, 1e-4)) - DB_FLOOR) / DB_SPAN); }
function lin(v) { return Math.pow(10, (clamp01(v) * DB_SPAN + DB_FLOOR) / 20); }
var PRESET_CURVES = {
smooth: { type: 'bezier', id: 'smooth', y0: 0, y1: 1, p1x: 0.45, p1y: 0.05, p2x: 0.55, p2y: 0.95 },
sharp: { type: 'bezier', id: 'sharp', y0: 0, y1: 1, p1x: 0.10, p1y: 0.65, p2x: 0.35, p2y: 1.00 },
fall: { type: 'bezier', id: 'fall', y0: 1, y1: 0, p1x: 0.45, p1y: 0.95, p2x: 0.55, p2y: 0.05 }
};
function encodeMeta(curve) {
if (!curve) return '';
if (curve.type === 'steps') return 's:' + curve.n;
if (curve.type === 'arch') return 'a';
if (curve.type === 'bezier') {
if (curve.id && PRESET_CURVES[curve.id]) return 'p:' + curve.id;
return 'b:' + [curve.y0, curve.y1, curve.p1x, curve.p1y, curve.p2x, curve.p2y]
.map(function (v) { return Math.round(clamp01(v) * 100); }).join(',');
}
return '';
}
function decodeMeta(m) {
if (!m) return null;
if (m === 'a') return { type: 'arch', id: 'arch' };
if (m.slice(0, 2) === 's:') {
var n = Math.max(2, Math.min(8, parseInt(m.slice(2), 10) || 2));
return { type: 'steps', id: n === 2 ? 'gate' : 'steps', n: n };
}
if (m.slice(0, 2) === 'p:') {
var p = PRESET_CURVES[m.slice(2)];
return p ? JSON.parse(JSON.stringify(p)) : null;
}
if (m.slice(0, 2) === 'b:') {
var q = m.slice(2).split(',').map(function (v) { return (parseInt(v, 10) || 0) / 100; });
if (q.length !== 6) return null;
return { type: 'bezier', id: 'custom', y0: q[0], y1: q[1], p1x: q[2], p1y: q[3], p2x: q[4], p2y: q[5] };
}
return null;
}
var frameFn = null;
var polling = false;
var micOn = false;
var phoneLive = false;
var audOn = false;
function poll() {
fetch('/api/audio-in?levels=1').then(function (r) { return r.json(); }).then(function (j) {
// ext frames come from the phone app, already on the editor's own
// normalized scale - converting them again would wreck them. The
// device's mic values are linear and get the dB treatment.
var ext = j.ext === true;
micOn = !ext && j.source !== 'off';
phoneLive = ext;
syncBar();
window.PFAdapter.labels.live = ext ? 'live · phone' : 'live · microphone';
var conv = ext ? function (v) { return v; } : dbn;
if (frameFn) frameFn({
running: ext || micOn,
connected: ext || micOn,
levels: (j.levels || []).map(conv),
outputs: j.outputs || [],
env: (j.env || []).map(function (e) { return { lo: conv(e.lo), hi: conv(e.hi) }; }),
spectrum: (j.spectrum || []).map(conv),
autoRange: true
});
}).catch(function () {
if (frameFn) frameFn({ running: false, connected: false, levels: [], env: [], spectrum: [] });
}).finally(function () {
// Chained, never setInterval: the device serves one connection at a
// time, and a timer would stack requests behind a slow one.
if (polling) setTimeout(poll, 100);
});
}
function post(body) {
return fetch('/api/audio-in', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body
}).catch(function () {});
}
window.PFAdapter = {
caps: function () { return { hzMin: 31.25, hzMax: 8000 }; },
labels: { live: 'live · microphone' },
captureHint: 'Turn the microphone on to hear the room.',
loadConfig: function () {
// Never reject: a failed read hands the editor its defaults and the
// page still stands - the poll loop keeps trying, and the next save
// writes the truth back.
return fetch('/api/audio-in').then(function (r) { return r.json(); }).then(function (j) {
micOn = !!j.micOn;
var gainEl = document.getElementById('micGain');
gainEl.value = String(j.micGain || 8);
document.getElementById('micGainVal').textContent = Number(j.micGain || 8).toFixed(1);
fetch('/api/audio').then(function (r) { return r.json(); }).then(function (a) {
audOn = !!a.audioRuntime;
syncBar();
}).catch(function () {});
syncBar();
return {
host: 'this device',
smoothing: j.smoothing || 0.35,
attack: j.attack || 0.65,
autoRange: !!j.autoRange,
bands: (j.bands || []).map(function (b) {
return {
hzMin: b.hzMin, hzMax: b.hzMax,
inMin: dbn(b.inMin), inMax: dbn(b.inMax),
gain: b.gain, outMin: b.outMin, outMax: b.outMax,
knob: b.knob, muted: b.muted,
curve: decodeMeta(b.meta), lut: null
};
})
};
}).catch(function () { return null; });
},
saveConfig: function (cfg) {
var parts = ['auto=' + (cfg.autoRange ? 1 : 0),
'smoothing=' + cfg.smoothing.toFixed(3),
'attack=' + cfg.attack.toFixed(3)];
cfg.bands.forEach(function (b, i) {
parts.push('hzMin' + i + '=' + b.hzMin.toFixed(1));
parts.push('hzMax' + i + '=' + b.hzMax.toFixed(1));
parts.push('inMin' + i + '=' + lin(b.inMin).toFixed(5));
parts.push('inMax' + i + '=' + lin(b.inMax).toFixed(5));
parts.push('gain' + i + '=' + b.gain.toFixed(3));
parts.push('outMin' + i + '=' + b.outMin.toFixed(3));
parts.push('outMax' + i + '=' + b.outMax.toFixed(3));
parts.push('knob' + i + '=' + b.knob);
parts.push('muted' + i + '=' + (b.muted ? 1 : 0));
parts.push('meta' + i + '=' + encodeURIComponent(encodeMeta(b.curve)));
if (Array.isArray(b.lut) && b.lut.length) {
parts.push('lut' + i + '=' + b.lut.map(function (v) {
return Math.round(clamp01(v) * 255);
}).join(','));
}
});
return post(parts.join('&'));
},
onFrame: function (fn) {
frameFn = fn;
if (!polling) { polling = true; poll(); }
},
requestStatus: function () {},
stop: function () { post('mic=0'); }
};
// ── the device bar ────────────────────────────────────────────────────
function syncBar() {
var audT = document.getElementById('audToggle');
if (audT) audT.classList.toggle('on', audOn);
var t = document.getElementById('micToggle');
if (t) t.classList.toggle('on', micOn);
var note = document.getElementById('deviceNote');
if (note) {
note.textContent = micOn ? ''
: phoneLive ? 'showing the phone app’s audio'
: 'microphone is off — the panel is not listening';
}
}
document.addEventListener('DOMContentLoaded', function () {
var audBtn = document.getElementById('audToggle');
if (audBtn) {
audBtn.addEventListener('click', function () {
audOn = !audOn;
syncBar();
fetch('/api/audio', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'on=' + (audOn ? 1 : 0)
}).catch(function () {});
});
}
document.getElementById('micToggle').addEventListener('click', function () {
micOn = !micOn;
syncBar();
post('mic=' + (micOn ? 1 : 0));
});
var gainTimer = null;
document.getElementById('micGain').addEventListener('input', function () {
var v = Number(document.getElementById('micGain').value);
document.getElementById('micGainVal').textContent = v.toFixed(1);
clearTimeout(gainTimer);
gainTimer = setTimeout(function () { post('micGain=' + v); }, 150);
});
document.getElementById('resetAll').addEventListener('click', function () {
if (!confirm('Reset every band, curve and the input gain to defaults?')) return;
fetch('/api/audio-in/reset', { method: 'POST' }).then(function () { location.reload(); });
});
syncBar();
});
})();
</script>
<script>
// 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();
})();
</script>
</body>
</html>