0
mirror of https://github.com/torvalds/GuitarPedal.git synced 2026-08-19 05:33:46 +00:00
Files
Linus Torvalds 0c1b9c3db3 Split Software/ into the four things it actually was
'Software' was the directory everything that was not KiCad ended up in,
which stopped describing anything a while ago - Validation and the web
app are software too.  Worse, it put the shared parts inside the
firmware, where they read as the firmware's own.

They are not.  Effects/ has three consumers built from it: the firmware,
Validation's bench, and the web app's controls, all generated from the
same POT: comments by gen_effects.py.  Audio/ has two - the bench
compiles the same biquads, the same envelope followers and the same
single_sample(), which is the whole reason a measurement on a
workstation says anything about the pedal.  Neither belongs under
Firmware/, so neither is under it any more:

  Effects/    one file per effect
  Audio/      the DSP they are built from, and the audio loop
  Firmware/   the rest of what runs on the pedal, and the submodules
  WebMIDI/    the web app
  scripts/    what the build runs
  Validation/ unchanged
  Hardware/, Documentation/, Images/

CMakeLists.txt and the wrapper Makefile move to the top with them,
because the build now consumes four of those directories and generates
into a fifth.  board.local and build/ come along; MIDI_CC_MAP.md is
generated into Documentation/ rather than into the old Software/ root.

scripts/ goes with the build rather than staying under the firmware,
because six of the ten had nothing to do with the firmware: gen_effects.py
reads Effects/ and writes to three different places, pow2/log2/quarter_sine
generate Audio/'s tables, check-readme.py compares Effects/ against the
README, and server.py serves the web app.  Four of them are invoked from
Validation, which was reaching into Firmware/ for tooling - the same
burying this commit is undoing.  The four that really are about the
firmware are ELF checks the top-level build drives anyway, and a second
scripts directory would only be a second place to look.

C includes say "Audio/foo.h" and the generated map says
"Effects/bar.h", with the repository root on the include path for both
the firmware and the bench.  Spelling the directory out rather than
relying on a bare name is what keeps Audio/cycles.h shimmable: a quoted
include searches the including file's own directory first.

The submodules are renamed as well as moved.  git mv updates their paths
but leaves the section names, and 'Software/pico-sdk' surviving in
.gitmodules would be the word this commit removes, still load-bearing.
That meant the nested modules under pico-sdk too - six .git files
pointing into .git/modules/Software - which is why 'git submodule update
--init --recursive' is worth running once after pulling this.

Verified rather than assumed: a clean configure and build, make check
(failing only on the missing-eeprom case it already failed on),
check-effects, all four analysis pages reproducing every series and
drawing every chart, and a flash to the board that still measures a
routed reverb where it did before.

One latent bug fell out of it.  bench/coeff declared only quarter_sine.h
of the three generated math tables, and Audio/util.h includes pow2.h and
log2.h as well - so building that target with an empty gen/ could never
have worked.  'make bench' builds bench/bench first, which generates all
three, so it stayed hidden until this rebuilt everything from nothing.

Signed-off-by: Linus Torvalds <torvalds@linux-foundation.org>
2026-08-11 13:48:26 -07:00

4185 lines
159 KiB
JavaScript

const SYSEX_CMD = {
REQ_SCHEMA: 0x01,
RES_SCHEMA: 0x02,
PARAM_UPDATE: 0x03,
SAVE_SCENE: 0x04,
REQ_STATE: 0x05,
ROUTING_ORDER: 0x08,
DIAGNOSTIC: 0x09,
IDENTITY: 0x0a,
TELEMETRY: 0x0b
};
//
// What the pedal's own controls do. Must match enum control_id and
// enum bind_action in bindings.h.
//
// There is no schema for these the way there is for effects, and there
// should not be: the set of gestures is fixed by what the hardware
// physically has, so it changes when a board does rather than when an
// effect header does.
//
const SYSEX_SET_BINDING = 0x0c;
const SYSEX_BINDINGS = 0x0d;
//
// The four rule tables. A gesture is answered by the most specific
// level that mentions its control at all, and that level answers it
// completely - so a scene rule does not add to a global one, it
// replaces it for that control.
//
// SCENE and GLOBAL are settings. EFFECTIVE is what those two and the
// defaults resolve to, and DEFAULT is what is compiled in; both are
// read-only, and the pedal refuses a write to either rather than
// pretending it worked.
//
const RULES_SCENE = 0;
const RULES_GLOBAL = 1;
const RULES_EFFECTIVE = 2;
const RULES_DEFAULT = 3;
const ACT = {
NONE: 0, POT: 1, NEXT_POT: 2, RESET_POT: 3,
SET_POT: 4, TOGGLE_POT: 5, BYPASS: 6, TUNER: 7, SCENE: 8
};
// A target effect of this means "whatever the knob is turning".
const BIND_FOLLOW = 0x7f;
//
// A target parameter is numbered the way the SysEx parameter write
// numbers it: 0 is the mix, 1..10 are the effect's own pots. The mix
// is not a pot and has no schema entry, so it needs a definition to be
// drawn from - the same one the effect cards use for their own mix
// slider.
//
const MIX_POT_DEF = { name: 'Mix', curve: 'LINEAR', min: 0, max: 100, unit: '%' };
const CONTROLS = [
{ name: 'Knob \u2014 turn', turns: true },
{ name: 'Knob \u2014 press', turns: false },
{ name: 'Knob \u2014 hold', turns: false },
{ name: 'Footswitch \u2014 press', turns: false },
{ name: 'Footswitch \u2014 hold', turns: false }
];
//
// 'target' is whether the action names a parameter, and 'follow' is
// whether that parameter may be "the knob's" rather than a named one.
// 'values' is how many numbers it carries.
//
const ACTIONS = [
{ v: ACT.NONE, label: 'Nothing' },
{ v: ACT.POT, label: 'Control', target: true },
{ v: ACT.NEXT_POT, label: 'Next parameter' },
{ v: ACT.RESET_POT, label: 'Reset to default',
target: true, follow: true },
{ v: ACT.SET_POT, label: 'Set value', target: true, values: 1 },
{ v: ACT.TOGGLE_POT, label: 'Toggle between', target: true, values: 2 },
{ v: ACT.BYPASS, label: 'Bypass' },
{ v: ACT.TUNER, label: 'Tuner' },
{ v: ACT.SCENE, label: 'Load scene' }
];
function actionDef(v) {
return ACTIONS.find(a => a.v === v) || ACTIONS[0];
}
//
// The pedal accepts any action on any control, because a useless
// binding is not a dangerous one and it has no business refusing
// something merely silly. Making the silly thing hard to say is this
// end's job: a control that turns can only drive a parameter, and one
// that clicks can do anything except that.
//
function actionsFor(ctrl) {
return ACTIONS.filter(a => CONTROLS[ctrl].turns
? (a.v === ACT.NONE || a.v === ACT.POT)
: a.v !== ACT.POT);
}
//
// The rule table, flat and in the pedal's order. A gesture appears in
// it as many times as it has things to do.
//
let pedalRules = [];
let haveRules = false;
//
// Every level the pedal reported, indexed by RULES_*. The editor works
// on the scene's table; the rest are here so the UI can say what a
// control actually does and what it would do if this rule went away.
//
let rulesByLevel = [[], [], [], []];
let midiAccess = null;
let midiInput = null;
let midiOutput = null;
// UI Elements
const appTitleEl = document.getElementById('app-title');
const globalEnableEl = document.getElementById('global-enable');
const effectsContainer = document.getElementById('effects-container');
// Map of CC to its HTML input element for O(1) updates
const ccToElementMap = new Map();
let isGlobalEnabled = false;
let activePotCc = null;
let activePotDef = null;
//
// The pedal filters Control Change and Program Change by
// settings.midi_channel and lets SysEx through regardless. Transmit on
// the wrong one and global bypass, the tuner toggle, scene loads and the
// reboot all go quietly nowhere, while parameter edits carry on working
// - which is a confusing thing to debug, so keep this in step with the
// pedal rather than assuming channel 1 forever.
//
let activeTransmitChannel = 0xB0;
// Which pot in the schema is the channel. Found rather than hardcoded,
// and found by 'base' - the effect header's filename - because that is
// a steadier handle than the display name (see issue 20).
let midiChannelRef = null;
//
// Which pot the pedal filters its incoming MIDI by, so the app can
// transmit on the same channel.
//
// The pedal says so - 'ROLE: CHANNEL:POT2' in the effect header, carried
// through in the schema. This used to look for an effect whose filename
// was 'settings' and then a pot whose label read "MIDI Ch", so renaming
// either quietly stopped the app tracking the channel, and quietly is
// the word: the fallback is transmitting on channel 1, which works
// perfectly until somebody sets a channel.
//
// A pedal older than the role tag has no answer here, and gets the same
// channel 1 it would have got before. Nothing looks for the label any
// more, which is the point.
//
function findMidiChannelPot() {
const idx = PEDAL_EFFECTS.findIndex(
(e) => e.roles && e.roles.CHANNEL !== undefined);
return idx < 0 ? null
: { idx, effId: PEDAL_EFFECTS[idx].id,
pot: PEDAL_EFFECTS[idx].roles.CHANNEL };
}
//
// Pot value 0 is Omni, 1..16 are Ch1..Ch16. On Omni the pedal takes
// whatever arrives, so channel 1 is as good as any.
//
function setTransmitChannel(potVal) {
activeTransmitChannel = 0xB0 | (potVal > 0 ? (potVal - 1) & 0x0F : 0);
// Keep the dialog's copy showing the same thing. Assigning .value
// fires nothing, so this can't come back round.
const sel = document.getElementById('midi-channel-select');
if (sel && parseInt(sel.value) !== potVal)
sel.value = potVal;
}
// Called once the schema has been rendered: pick the channel up from the
// control, and follow it when it is changed here. Changes arriving from
// the pedal come through the PARAM_UPDATE case instead, because setting
// .value from script fires no event.
function bindMidiChannel() {
midiChannelRef = findMidiChannelPot();
if (!midiChannelRef)
return;
const el = ccToElementMap.get(`eff-${midiChannelRef.idx}-pot-${midiChannelRef.pot}`);
if (!el)
return;
//
// The dialog gets a second view of this pot, built from the same
// schema enum so the names can't drift. It is a view and not a
// second setting: changing it drives the card's control, which is
// the one wired to the pedal, so there is still exactly one place
// that decides what gets sent.
//
const sel = document.getElementById('midi-channel-select');
if (sel) {
const names = PEDAL_EFFECTS[midiChannelRef.idx].pots[midiChannelRef.pot].enum || [];
sel.innerHTML = '';
names.forEach((name, i) => {
const opt = document.createElement('option');
opt.value = i;
opt.textContent = name;
sel.appendChild(opt);
});
sel.addEventListener('change', () => {
el.value = sel.value;
el.dispatchEvent(new Event('change'));
});
}
el.addEventListener('change', () => setTransmitChannel(parseInt(el.value)));
setTransmitChannel(parseInt(el.value));
}
// Tuner State
let isTunerMode = false;
let playSynth = false;
const tunerState = Array(9).fill().map(() => ({ note: 0, cents: 0, volume: 0 }));
const noteNames = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
// Web Audio API Synth
let audioCtx = null;
const synthVoices = {}; // ch -> { osc, gain, filter, note }
function getAudioContext() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
return audioCtx;
}
function midiNoteToFreq(note, cents = 0) {
return 440 * Math.pow(2, (note - 69 + cents / 100) / 12);
}
function startNoteVoice(ch, note, cents) {
if (!playSynth) return;
const ctx = getAudioContext();
if (synthVoices[ch]) stopNoteVoice(ch);
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const filter = ctx.createBiquadFilter();
osc.type = 'sawtooth';
osc.frequency.value = midiNoteToFreq(note, cents);
filter.type = 'lowpass';
filter.frequency.value = 1500;
osc.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
gain.gain.setValueAtTime(0, ctx.currentTime);
gain.gain.linearRampToValueAtTime(0.1, ctx.currentTime + 0.05);
osc.start();
synthVoices[ch] = { osc, gain, filter, note };
}
function updateNoteVoiceBend(ch, note, cents) {
if (synthVoices[ch] && synthVoices[ch].note === note) {
const ctx = getAudioContext();
synthVoices[ch].osc.frequency.setTargetAtTime(midiNoteToFreq(note, cents), ctx.currentTime, 0.05);
}
}
function updateNoteVoiceVolume(ch, volume) {
if (synthVoices[ch]) {
const ctx = getAudioContext();
// map 0-127 to a reasonable gain max, e.g., 0.2
const targetGain = (volume / 127.0) * 0.2;
synthVoices[ch].gain.gain.setTargetAtTime(targetGain, ctx.currentTime, 0.05);
}
}
function stopNoteVoice(ch) {
if (synthVoices[ch]) {
const { osc, gain } = synthVoices[ch];
const ctx = getAudioContext();
gain.gain.cancelScheduledValues(ctx.currentTime);
gain.gain.setValueAtTime(gain.gain.value, ctx.currentTime);
gain.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.1);
osc.stop(ctx.currentTime + 0.15);
delete synthVoices[ch];
}
}
function stopAllNotes() {
for (let ch in synthVoices) stopNoteVoice(ch);
}
// Initialize MIDI
async function initMidi() {
try {
console.log("[WebMIDI] Requesting MIDI access...");
//
// Missing entirely, which is not the same as refused, and there
// are two quite different reasons for it.
//
// Web MIDI is only exposed on a secure origin, and http:// to a
// LAN address is not one - which is exactly how this gets tested
// from a phone. That doesn't fail the call, it removes the API,
// so the SecurityError branch below never sees it and the honest
// "HTTPS Required" was unreachable. Blaming the browser sends
// you off checking the one thing that isn't wrong.
//
if (!navigator.requestMIDIAccess) {
const insecure = !window.isSecureContext;
appTitleEl.textContent = insecure ? "HTTPS Required" : "Browser Not Supported";
console.error(insecure
? "Web MIDI needs a secure origin: https, localhost, or this origin allowed in chrome://flags/#unsafely-treat-insecure-origin-as-secure"
: "Web MIDI API is not supported in this browser.");
return;
}
midiAccess = await navigator.requestMIDIAccess({ sysex: true });
midiAccess.onstatechange = updateMidiState;
updateMidiState();
} catch (err) {
console.error("MIDI access denied", err);
if (err.name === 'SecurityError') {
appTitleEl.textContent = "HTTPS Required";
} else if (err.name === 'NotAllowedError') {
appTitleEl.textContent = "Permission Denied";
} else if (err.name === 'InvalidStateError') {
appTitleEl.textContent = "Tap to Connect";
} else {
appTitleEl.textContent = "MIDI Error: " + (err.name || "Unknown");
}
}
}
let selectedInputId = null;
let selectedOutputId = null;
//
// Which of the ports on offer is a pedal.
//
// The pedal names itself after the codec it found - "TAC5242 Pedal",
// "TAC5112 Pedal" - so the only part common to all of them is the word
// the match is now on. As a word, so that something called "Pedalboard"
// is not mistaken for one.
//
// With two pedals plugged in both match and the first one wins. That is
// what the selector below is for: auto-detect answers "a pedal", and
// saying which pedal is a choice only the person at the desk can make.
//
const PEDAL_PORT = /(^|\s)Pedal(\s|$)/;
function populateMidiSelects() {
const inSelect = document.getElementById('midi-input-select');
const outSelect = document.getElementById('midi-output-select');
if (!inSelect || !outSelect) return;
inSelect.innerHTML = '<option value="">-- Auto-detect pedal --</option>';
outSelect.innerHTML = '<option value="">-- Auto-detect pedal --</option>';
for (let input of midiAccess.inputs.values()) {
const opt = document.createElement('option');
opt.value = input.id;
opt.textContent = input.name;
if (input.id === selectedInputId) opt.selected = true;
inSelect.appendChild(opt);
}
for (let output of midiAccess.outputs.values()) {
const opt = document.createElement('option');
opt.value = output.id;
opt.textContent = output.name;
if (output.id === selectedOutputId) opt.selected = true;
outSelect.appendChild(opt);
}
}
function updateMidiState() {
let foundInput = null;
let foundOutput = null;
//
// Every device, every time the MIDI state changes. Useful when a
// pedal is not showing up and noise the rest of the time, and it is
// by far the most of what lands in the console - so it is debug
// output, which browsers hide until asked.
//
console.debug("[WebMIDI] updating MIDI state. Available inputs:");
for (let input of midiAccess.inputs.values()) {
console.debug(" Input:", input.name, input.id);
}
console.debug("[WebMIDI] Available outputs:");
for (let output of midiAccess.outputs.values()) {
console.debug(" Output:", output.name, output.id);
}
if (selectedInputId && midiAccess.inputs.has(selectedInputId)) {
foundInput = midiAccess.inputs.get(selectedInputId);
} else {
for (let input of midiAccess.inputs.values()) {
if (!foundInput) foundInput = input;
if (PEDAL_PORT.test(input.name)) {
foundInput = input;
break;
}
}
if (foundInput && !selectedInputId) selectedInputId = foundInput.id;
}
if (selectedOutputId && midiAccess.outputs.has(selectedOutputId)) {
foundOutput = midiAccess.outputs.get(selectedOutputId);
} else {
for (let output of midiAccess.outputs.values()) {
if (!foundOutput) foundOutput = output;
if (PEDAL_PORT.test(output.name)) {
foundOutput = output;
break;
}
}
if (foundOutput && !selectedOutputId) selectedOutputId = foundOutput.id;
}
populateMidiSelects();
if (foundInput && foundOutput) {
if (midiInput !== foundInput) {
midiInput = foundInput;
midiInput.onmidimessage = handleMidiMessage;
}
midiOutput = foundOutput;
appTitleEl.className = "title-connected";
appTitleEl.textContent = `Connected: ${foundInput.name}`;
// Who is this, then what does it have
sendSysex([SYSEX_CMD.IDENTITY]);
updateTelemetryPolling();
sendSysex([SYSEX_CMD.REQ_SCHEMA]);
sendSysex([SYSEX_CMD.DIAGNOSTIC]); // Request diagnostic status
} else {
midiInput = null;
midiOutput = null;
appTitleEl.className = "title-disconnected";
appTitleEl.textContent = "RP2350 Pedal";
// Nothing is going to tell us it stopped, so stop saying it
clearPedalStatus();
updateTelemetryPolling();
}
}
//
// Are we the copy served off a laptop, or the deployed one?
//
// The two want to behave differently in small ways, and the difference
// was already being made once - index.html keeps the service worker
// away from a local page, because a stale worker outliving several
// reloads is a miserable way to spend an afternoon. This is that same
// test, made once and shared, so the two can't drift apart.
//
// An empty hostname is a file:// URL.
//
// A private address counts as local too, because testing on a phone
// means serving to 192.168.x.x and the page is no less "the copy I am
// editing" for having crossed the room to get there. That matters more
// than it looks: getting Web MIDI to work over http at all takes
// whitelisting the origin in Chrome's
// #unsafely-treat-insecure-origin-as-secure, and an origin secure
// enough for Web MIDI is secure enough to register a service worker.
// So without this the phone - the machine hardest to clear a stale
// worker out of - would be the one caching the dev server.
//
// The bracket is not optional: location.hostname keeps them on an IPv6
// literal, so requiring one is what stops this matching a real name that
// happens to start "fd".
//
const PRIVATE_HOST = /^(10\.|127\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|\[(::1|fe80|f[cd]))|\.local$/i;
const IS_LOCAL_DEV = ['localhost', '127.0.0.1', '::1', ''].includes(location.hostname) ||
PRIVATE_HOST.test(location.hostname);
// Colours the title amber rather than green - see style.css. The tooltip
// is set here rather than in the markup so the deployed copy doesn't
// carry an explanation of something it doesn't do.
if (IS_LOCAL_DEV) {
document.body.classList.add('local-dev');
if (appTitleEl)
appTitleEl.title = 'Served locally — confirmations for destructive actions are skipped';
}
const updateAppBtn = document.getElementById('update-app-btn');
if (updateAppBtn) {
updateAppBtn.addEventListener('click', async () => {
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (let reg of registrations) {
await reg.unregister();
}
}
if ('caches' in window) {
const keys = await caches.keys();
for (let key of keys) {
await caches.delete(key);
}
}
window.location.reload();
});
}
let diagnosticTimeout = null;
function scheduleDiagnostic() {
if (diagnosticTimeout) clearTimeout(diagnosticTimeout);
diagnosticTimeout = setTimeout(() => {
sendSysex([SYSEX_CMD.DIAGNOSTIC]);
}, 1000);
}
function sendSysex(data) {
if (!midiOutput) return;
const msg = new Uint8Array([0xF0, 0x7D, ...data, 0xF7]);
midiOutput.send(msg);
//
// Having done something, ask whether it went wrong. Queries are not
// doing something, and telemetry especially is not: it is polled
// several times a second, and dragging a diagnostic request along with
// every frame would double the traffic and drain the pedal's one
// status message before anybody could look at it.
//
const query = [SYSEX_CMD.DIAGNOSTIC, SYSEX_CMD.REQ_SCHEMA,
SYSEX_CMD.REQ_STATE, SYSEX_CMD.IDENTITY,
SYSEX_CMD.TELEMETRY];
if (!query.includes(data[0]))
scheduleDiagnostic();
}
let PEDAL_EFFECTS = [];
//
// In / Out / Merge, declared once by the pedal rather than per effect.
// Null against firmware that predates them, which is what every check
// of it is for.
//
let PEDAL_STEERING = null;
let effectIdMap = new Map();
//
// One pot value from the pedal, onto whatever draws it.
//
// Split out of the PARAM_UPDATE case so that a message carrying several
// pots applies them the same way it applies one. Everything here is
// per-pot; the effect id is the only thing shared across a message.
//
function applyPotValue(effId, potIdx, val) {
// The pedal telling us its channel changed - on a scene
// load, say. Do this before the element update below, which
// sets .value from script and so fires nothing.
if (midiChannelRef && effId === midiChannelRef.effId &&
potIdx === midiChannelRef.pot + 1)
setTransmitChannel(val);
const idx = effectIdMap.get(effId);
if (idx === undefined) return;
const idKey = potIdx === 0 ? `eff-${idx}-mix` : `eff-${idx}-pot-${potIdx-1}`;
const el = ccToElementMap.get(idKey);
if (!el) return;
if (el.type === 'checkbox') {
el.checked = (val > 0);
} else if (el.tagName === 'SELECT') {
el.value = val;
} else if (el.type === 'range') {
el.value = val;
const valDisplay = el.parentElement.querySelector('.pot-value');
if (valDisplay && el.potDef) {
valDisplay.textContent = formatPotValue(el.potDef, val);
}
if (el.redrawCurve) {
el.redrawCurve();
}
if (activePotDef && activePotCc === idKey) { // activePotCc is now acting as string key
const activeSlider = document.getElementById('active-pot-slider');
if (activeSlider) activeSlider.value = val;
const activeValue = document.getElementById('active-pot-value');
if (activeValue) activeValue.textContent = formatPotValue(activePotDef, val);
}
}
}
function handleSysex(data) {
const cmd = data[2];
console.debug(`[WebMIDI] Received SysEx cmd=0x${cmd.toString(16)}, data=[${Array.from(data).map(b => '0x' + b.toString(16).padStart(2, '0')).join(', ')}]`);
switch (cmd) {
case SYSEX_CMD.RES_SCHEMA: {
// Schema Response
let jsonStr = '';
for (let i = 3; i < data.length - 1; i++) {
jsonStr += String.fromCharCode(data[i]);
}
try {
//
// The schema used to be a bare array of effects and is
// now an object with the effects under a key, alongside
// the steering parameters every effect shares. Both
// shapes are accepted: a cached copy of this app can
// meet firmware older than itself, and a service worker
// makes that likelier than it sounds.
//
const parsed = JSON.parse(jsonStr);
PEDAL_EFFECTS = Array.isArray(parsed) ? parsed : parsed.effects;
PEDAL_STEERING = Array.isArray(parsed) ? null : parsed.steering;
effectIdMap.clear();
PEDAL_EFFECTS.forEach((e, idx) => effectIdMap.set(e.id, idx));
renderUI();
bindMidiChannel();
// Request State and Status
sendSysex([SYSEX_CMD.REQ_STATE]);
sendSysex([SYSEX_CMD.DIAGNOSTIC]);
} catch (e) {
console.error("Failed to parse schema", e);
}
break;
}
case SYSEX_CMD.IDENTITY: {
let jsonStr = '';
for (let i = 3; i < data.length - 1; i++)
jsonStr += String.fromCharCode(data[i]);
try {
const id = JSON.parse(jsonStr);
//
// Log the whole thing rather than the fields we happen
// to render. It is a handful of bytes once per connect,
// and it means a field added to answer a question on the
// bench is visible without the app having to learn it
// first.
//
console.log('[Pedal Identity]', id);
handleIdentity(id);
} catch (e) {
console.error("Failed to parse identity", e);
}
break;
}
case SYSEX_CMD.TELEMETRY:
handleTelemetry(data);
break;
case SYSEX_CMD.DIAGNOSTIC: {
// Diagnostic Response
let jsonStr = '';
for (let i = 3; i < data.length - 1; i++) {
jsonStr += String.fromCharCode(data[i]);
}
if (jsonStr.trim() !== '') {
console.log(`[Pedal Diagnostic] ${jsonStr.trim()}`);
}
break;
}
case SYSEX_CMD.PARAM_UPDATE: {
//
// Set Parameter: one effect, then as many (pot, value)
// pairs as the message carries.
//
// A message with one pair is byte for byte the message this
// used to be, so nothing had to change about what the pedal
// sends when a knob moves - only about what a state dump is
// allowed to pack into one message.
//
if (data.length < 6) break;
const effId = data[3];
for (let at = 4; at + 1 < data.length; at += 2)
applyPotValue(effId, data[at], data[at + 1]);
break;
}
case SYSEX_BINDINGS: {
const level = data[3];
const list = [];
for (let at = 4; at + 5 < data.length; at += 6) {
list.push({ control: data[at],
action: data[at + 1],
effect: data[at + 2],
pot: data[at + 3],
val: [data[at + 4], data[at + 5]] });
}
rulesByLevel[level] = list;
//
// The editor still edits one table, and it is the scene's.
// The other three are what it draws around that: what a
// control ends up doing, and what it would fall back to.
//
if (level === RULES_SCENE) {
pedalRules = list;
haveRules = true;
}
renderBindings();
renderKnobHint();
break;
}
// Raw eeprom cache, 64 bytes as ASCII hex. Ask for it with
// dumpEeprom(n) from the console; n counts 64-byte blocks.
case 0x0f: {
let hex = '';
for (let i = 4; i < data.length - 1; i++)
hex += String.fromCharCode(data[i]);
const off = data[3] * 64;
const bytes = hex.match(/../g) || [];
console.log(`[EEPROM ${off.toString(16).padStart(4, '0')}] ` +
bytes.join(' '));
break;
}
case SYSEX_CMD.ROUTING_ORDER: {
// Routing order
const routeIds = [];
for (let i = 3; i < data.length - 1; i++) {
routeIds.push(data[i]);
}
applyRouting(routeIds);
// A state dump always concludes with the routing order.
// Fetch status now to pick up any "Sent state dump" or similar info.
sendSysex([SYSEX_CMD.DIAGNOSTIC]);
break;
}
}
}
function handleMidiMessage(event) {
if (event.data[0] === 0xF0) {
if (event.data[1] === 0x7D) handleSysex(event.data);
else console.debug(`[WebMIDI] Unknown SysEx: [${Array.from(event.data).map(b => '0x' + b.toString(16).padStart(2, '0')).join(', ')}]`);
return;
}
const [status, data1, data2] = event.data;
console.debug(`[WebMIDI] Received normal MIDI message: status=0x${status ? status.toString(16) : 'undefined'}, data1=${data1}, data2=${data2}`);
// Control Change (0xB0 to 0xBF, we just mask to 0xB0 for channel 1)
if ((status & 0xF0) === 0xB0) {
const cc = data1;
const val = data2;
if (cc === GLOBAL_ENABLE_CC) {
if (val === 68 || val === 69) {
isTunerMode = (val === 68);
updateTunerModeUI();
} else {
isGlobalEnabled = (val > 0);
globalEnableEl.checked = isGlobalEnabled;
}
} else if (cc === STATUS_GLOBAL_CC) {
handleGlobalStatus(val);
} else if (cc === STATUS_CHAIN_LO_CC) {
statusChainBits = (statusChainBits & ~((1 << STATUS_CHAIN_BITS) - 1)) | val;
renderAttention();
} else if (cc === STATUS_CHAIN_HI_CC) {
statusChainBits = (statusChainBits & ((1 << STATUS_CHAIN_BITS) - 1)) |
(val << STATUS_CHAIN_BITS);
renderAttention();
}
} else if ((status & 0xF0) === 0x90) { // Note On
const ch = status & 0x0F;
const note = data1;
const vel = data2;
if (vel > 0) {
if (ch < 9) {
tunerState[ch].note = note;
startNoteVoice(ch, note, tunerState[ch].cents);
}
} else {
if (ch < 9) {
tunerState[ch].note = 0;
stopNoteVoice(ch);
}
}
updateTunerDisplay();
} else if ((status & 0xF0) === 0x80) { // Note Off
const ch = status & 0x0F;
if (ch < 9) {
tunerState[ch].note = 0;
stopNoteVoice(ch);
}
updateTunerDisplay();
} else if ((status & 0xF0) === 0xE0) { // Pitch Bend
const ch = status & 0x0F;
const bend = data1 | (data2 << 7);
if (ch < 9) {
tunerState[ch].cents = Math.round((bend - 8192) / 41);
if (tunerState[ch].note > 0) {
updateNoteVoiceBend(ch, tunerState[ch].note, tunerState[ch].cents);
}
}
updateTunerDisplay();
} else if ((status & 0xF0) === 0xD0) { // Channel Pressure
const ch = status & 0x0F;
const pressure = data1;
if (ch < 9) {
tunerState[ch].volume = pressure;
if (tunerState[ch].note > 0) {
updateNoteVoiceVolume(ch, pressure);
}
}
updateTunerDisplay();
}
}
function updateTunerModeUI() {
const tunerBtn = document.getElementById('tuner-btn');
const tunerPanel = document.getElementById('tuner-panel');
if (tunerBtn && tunerPanel) {
if (isTunerMode) {
tunerBtn.classList.add('active');
tunerPanel.classList.remove('hidden');
} else {
tunerBtn.classList.remove('active');
tunerPanel.classList.add('hidden');
}
}
}
function updateTunerDisplay() {
if (!isTunerMode) return;
const chrom = tunerState[0];
const chromNoteEl = document.getElementById('tuner-chromatic-note');
const chromCentsEl = document.getElementById('tuner-chromatic-cents');
const chromNeedle = document.getElementById('tuner-needle');
if (chromNoteEl && chromNeedle) {
if (chrom.note) {
const name = noteNames[chrom.note % 12];
const octave = Math.floor(chrom.note / 12) - 1;
chromNoteEl.textContent = `${name}${octave}`;
if (chromCentsEl) {
const sign = chrom.cents > 0 ? '+' : '';
chromCentsEl.textContent = `${sign}${chrom.cents}¢`;
}
let leftPercent = 50 + chrom.cents;
leftPercent = Math.max(0, Math.min(100, leftPercent));
chromNeedle.style.left = `${leftPercent}%`;
chromNeedle.style.display = 'block';
if (Math.abs(chrom.cents) < 5) {
chromNeedle.style.backgroundColor = 'var(--success)';
} else {
chromNeedle.style.backgroundColor = 'var(--danger)';
}
const chromVolEl = document.getElementById('tuner-chromatic-vol');
if (chromVolEl) chromVolEl.textContent = `V: ${chrom.volume}`;
} else {
chromNoteEl.textContent = '--';
if (chromCentsEl) chromCentsEl.textContent = '0¢';
chromNeedle.style.display = 'none';
const chromVolEl = document.getElementById('tuner-chromatic-vol');
if (chromVolEl) chromVolEl.textContent = `V: 0`;
}
}
const polyContainer = document.getElementById('tuner-poly-strings');
if (polyContainer) {
if (polyContainer.children.length === 0) {
for (let i = 1; i <= 6; i++) {
const strDiv = document.createElement('div');
strDiv.className = 'tuner-string';
strDiv.innerHTML = `
<div class="tuner-string-name" id="str-name-${i}">-</div>
<div class="tuner-string-arrow inactive" id="str-arrow-${i}"></div>
<div class="tuner-string-vol" id="str-vol-${i}">0</div>
`;
polyContainer.appendChild(strDiv);
}
}
for (let i = 1; i <= 6; i++) {
const state = tunerState[i];
const nameEl = document.getElementById(`str-name-${i}`);
const arrowEl = document.getElementById(`str-arrow-${i}`);
const volEl = document.getElementById(`str-vol-${i}`);
if (state && state.note) {
nameEl.textContent = noteNames[state.note % 12];
nameEl.classList.add('active');
arrowEl.className = 'tuner-string-arrow';
if (state.cents > 10) arrowEl.classList.add('down');
else if (state.cents < -10) arrowEl.classList.add('up');
else arrowEl.classList.add('perfect');
if (volEl) volEl.textContent = state.volume;
} else {
nameEl.classList.remove('active');
arrowEl.className = 'tuner-string-arrow inactive';
if (volEl) volEl.textContent = "0";
}
}
}
}
function sendMidiPc(pc) {
if (!midiOutput) return;
midiOutput.send([0xC0 | (activeTransmitChannel & 0x0F), pc]);
scheduleDiagnostic();
}
function sendMidiCc(cc, val) {
if (!midiOutput) return;
midiOutput.send([activeTransmitChannel, cc, val]);
scheduleDiagnostic();
}
//
// Let the scroll wheel adjust whichever slider it is over.
//
// The catch is that wheel events go to whatever happens to be under the
// pointer, and the pointer doesn't move while you scroll - the page does.
// So a naive version means that scrolling past a slider hands it the
// wheel, and since the page keeps moving underneath, you end up dragging
// one value after another while trying to get down the list. Every one
// of those goes straight to the pedal.
//
// A scroll gesture has a beginning, though, so settle it there: whatever
// was under the pointer when the gesture started owns the whole gesture.
// Start on a slider and it's yours until you pause; start anywhere else
// and sliders sliding past underneath are ignored.
//
const WHEEL_NOTCH = 100; // about one detent of a mouse wheel
const WHEEL_GESTURE_GAP = 400; // ms of quiet before it counts as a new one
const WHEEL_STEP = 3; // pot units per detent - 40 of them end to end
const WHEEL_STEP_FINE = 1; // ...and with shift held
const wheelZones = new Map(); // hoverable area -> the slider it drives
let wheelOwner = null;
let wheelLast = 0;
function wheelZoneAt(el) {
for (; el; el = el.parentElement)
if (wheelZones.has(el))
return el;
return null;
}
// Capture, so this settles ownership before the zone's own handler runs
window.addEventListener('wheel', (e) => {
if (e.timeStamp - wheelLast > WHEEL_GESTURE_GAP) {
wheelOwner = wheelZoneAt(e.target);
//
// Move the focus to whatever the wheel just took charge of.
//
// The ring is the only thing on screen saying which slider is
// about to move, and it used to follow clicks while the wheel
// followed the pointer - so clicking one and then scrolling
// over another left it pointing at the wrong control. One
// notion of "the active pot" rather than two, and the arrow
// keys now act on whatever the wheel last did.
//
// preventScroll because the default is to scroll the element
// into view, and doing that in the middle of a scroll would
// fight the user for the thing they are actually doing.
//
const input = wheelOwner && wheelZones.get(wheelOwner);
if (input && input !== document.activeElement)
input.focus({ preventScroll: true });
}
wheelLast = e.timeStamp;
}, { passive: true, capture: true });
//
// 'zone' is the area the wheel answers over. It wants to be the whole
// pot - name, readout and slider together - because aiming at the few
// pixels of the slider itself just reads as "it doesn't work".
//
function enableWheelAdjust(input, zone = input) {
let acc = 0;
wheelZones.set(zone, input);
zone.addEventListener('wheel', (e) => {
if (wheelOwner !== zone)
return;
e.preventDefault();
// Accumulate, so that a trackpad's stream of small deltas adds
// up to the same thing as one click of a wheel
acc -= e.deltaY; // wheel up raises the value
const notches = Math.trunc(acc / WHEEL_NOTCH);
if (!notches)
return;
acc -= notches * WHEEL_NOTCH;
const step = e.shiftKey ? WHEEL_STEP_FINE : WHEEL_STEP;
const now = parseInt(input.value);
const val = Math.max(parseInt(input.min),
Math.min(parseInt(input.max), now + notches * step));
if (val === now)
return;
// Go through the normal 'input' path, so the readout, the eq
// curve and the sysex all happen exactly as they would if you
// had dragged it
input.value = val;
input.dispatchEvent(new Event('input'));
}, { passive: false });
}
//
// The pot curves, once each way round.
//
// A pot is a number from 0 to 120 on the wire and something physical to
// look at - hertz, decibels, milliseconds - and which curve joins the
// two is declared in the effect's C header and comes through in the
// schema. gen_effects.py holds the same pair for the firmware's own
// table; these are the app's copy, and everything in the app goes
// through them.
//
// There were four. These two, and a second pair private to the
// parametric EQ that knew LINEAR and FREQUENCY and quietly handed back
// the raw pot value for anything else - so giving that effect a curve
// of a different kind would have drawn a flat line through 0 to 120 Hz
// and called it a response.
//
function clampPot(val) {
return Math.max(0, Math.min(120, isNaN(val) ? 0 : val));
}
function potToValue(pot, val) {
const p = val / 120.0;
switch (pot.curve) {
case 'LINEAR': return pot.min + p * (pot.max - pot.min);
case 'FREQUENCY': return pot.min + p * p * p * (pot.max - pot.min);
case 'SQUARED': return pot.min + p * p * (pot.max - pot.min);
case 'EXPONENTIAL': return pot.min * Math.pow(pot.max / pot.min, p);
}
return val; // RAW and ENUM are already the value
}
//
// And back. Rounding to a whole pot step is part of the answer rather
// than something for the caller to do afterwards: 121 values is all
// there is, so anything finer is a number the pedal cannot be told.
//
function valueToPot(pot, y) {
if (pot.curve === 'RAW' || pot.curve === 'ENUM')
return clampPot(Math.round(y));
const a = pot.min, b = pot.max;
let p = 0;
// A ratio outside the declared range is the caller's business to
// clamp; taking a cube root of a negative one here is not, and
// neither is a logarithm of it
if (b !== a) {
const ratio = Math.max(0, (y - a) / (b - a));
switch (pot.curve) {
case 'LINEAR': p = ratio; break;
case 'FREQUENCY': p = Math.cbrt(ratio); break;
case 'SQUARED': p = Math.sqrt(ratio); break;
case 'EXPONENTIAL':
p = (a > 0 && y > 0) ? Math.log2(y / a) / Math.log2(b / a) : 0;
break;
}
}
return clampPot(Math.round(p * 120));
}
//
// How the app behaves, as opposed to what the pedal does.
//
// Kept in localStorage because these belong to the person and the
// screen, not to the pedal: a scene must not be able to change how
// dragging works, and the pedal has no idea any of this exists. Which
// is also why they are not pots - there is nothing here to send.
//
function uiPref(key, fallback) {
try {
const val = localStorage.getItem('ui.' + key);
return val === null ? fallback : val === 'true';
} catch (err) {
// Storage can be refused outright, and a preference is not
// worth failing over
return fallback;
}
}
function setUiPref(key, val) {
try {
localStorage.setItem('ui.' + key, val ? 'true' : 'false');
} catch (err) {
/* then it lasts as long as the page does, which will do */
}
}
//
// Whether the EQ's bands are held in order while you drag them.
//
// On by default, and a switch rather than a decision made for you: the
// unordered curves the old overlapping pot ranges allowed were odd but
// not wrong, and some of them are interesting. Turning this off gets
// them back.
//
// Nothing happens retroactively. Switching it on does not tidy up bands
// that are already crossed - it only governs what a drag is allowed to
// do, so an existing curve is left exactly as it was until you take
// hold of something.
//
let eqKeepOrder = uiPref('eq.keepOrder', true);
//
// Whether the phase response is drawn behind the magnitude.
//
// Dark grey on black, so it is there if you look for it and not
// otherwise. Phase is worth being able to see - five sections in
// series is a lot of it, and the arrangements that look strange on the
// magnitude plot are where it is least obvious - but it is reference
// rather than the thing being edited, and the nodes and the curve stay
// the subject.
//
let eqShowPhase = uiPref('eq.showPhase', true);
//
// Keeping the EQ's bands in order.
//
// The pedal does not care - five biquads in series commute, and every
// band now has the whole audio range - so this is entirely a question
// about what a control should do, and it lives here rather than in the
// firmware.
//
// A band may travel as far as its neighbours and no further. The
// alternative was to push the neighbours along ahead of it, which reads
// well until you drag the low shelf up to 10kHz and arrive to find it
// has taken the other four with it and flattened a curve you spent a
// while on. Clamping never touches a band you did not grab, and it
// costs nothing: every ordered arrangement is still reachable, by
// moving the band that is in the way first, and unordered ones are what
// this is for.
//
// In pot units rather than hertz. Pot units are what gets stored and
// what gets sent, so clamping there is exact - clamp in hertz and the
// conversion back can round to a step the far side of the neighbour it
// was just clamped to.
//
function clampToNeighbours(vals, idx, want) {
const lo = idx > 0 ? vals[idx - 1] : 0;
const hi = idx < vals.length - 1 ? vals[idx + 1] : 120;
return Math.max(lo, Math.min(hi, want));
}
//
// Which bands are sitting exactly on top of this one.
//
// Clamping produces exact equality rather than near misses - a band
// stopped by its neighbour is given that neighbour's value - so a pile
// can be identified by that alone, with no tolerance to tune. Two bands
// that are merely close are not a pile, and go on being told apart by
// which one you aimed at.
//
function pileAt(vals, idx) {
const pile = [];
for (let i = 0; i < vals.length; i++)
if (vals[i] === vals[idx])
pile.push(i);
return pile;
}
//
// A frequency, short enough to sit under a control point.
//
// Two significant figures, because that is about all the control can
// select. The frequency pots step by 5.9%, so a third digit is a claim
// about precision that does not exist: "1.68kHz" was a reading of a
// number rather than a description of a setting, and the next position
// along would have been 1.78.
//
function formatFreqShort(freq) {
const mag = Math.pow(10, 1 - Math.floor(Math.log10(freq)));
const round = Math.round(freq * mag) / mag;
if (round < 1000)
return round.toFixed(0) + 'Hz';
const k = round / 1000;
return (k >= 10 ? k.toFixed(0) : k.toFixed(1)) + 'kHz';
}
function formatPotValue(pot, val) {
const y = potToValue(pot, val);
let displayStr = "";
if (pot.curve === 'RAW' || pot.curve === 'ENUM') {
displayStr = Math.round(y).toString();
} else {
// Drop trailing zeros, max 2 decimals
displayStr = parseFloat(y.toFixed(2)).toString();
}
if (pot.unit && pot.unit !== 'none') {
displayStr += ' ' + pot.unit;
}
return displayStr;
}
//
// Dragging effect cards around.
//
// This is done with pointer events rather than html5 drag-and-drop.
// html5 dnd is a file-transfer api from 2008 with a stateful 'draggable'
// attribute, drop events that only fire over registered targets, and no
// touch support worth the name - ios safari has none at all, so
// reordering simply did not work on a phone. Pointer events are one
// code path for mouse, touch and pen, and capturing the pointer means
// every move and the release come to us no matter what ends up under
// the cursor.
//
// The card is moved in the list as you go, rather than dragging a
// floating copy about: it is less code, and the list ends up in exactly
// the state that routingFromDOM() then reads back out of the dom.
//
// Dragging reorders the chain and does nothing else. It used to also be
// how an effect was routed and unrouted - drag it across a divider - and
// that was one gesture too many for it: on a phone it meant a long drag
// past every card you were not interested in, and it made card position
// the routing model. Routing is buttons now, and this is left doing the
// one thing a drag is genuinely good at.
//
const DRAG_THRESHOLD = 4; // px before a press counts as a drag
const DRAG_EDGE = 80; // px from the edge where we start scrolling
const DRAG_SCROLL_MAX = 18; // px per frame at the very edge
let cardDrag = null;
let dragScrollRaf = 0;
// Is this element a card that is currently in the chain?
function isRoutedCard(el) {
if (!el.dataset || el.dataset.effectId === undefined)
return false;
return currentRouting.includes(parseInt(el.dataset.effectId));
}
//
// Which element should the gap sit in front of?
//
// Only chain cards are landmarks. Everything else in the container -
// the anchor at the front, the settings card, the parked cards and the
// pool - is somewhere a dragged card may not go, so the search walks
// past them and the chain's own extent is what bounds the drag. There
// is nothing to clamp and no divider to be on the wrong side of.
//
function dragInsertionRef(y) {
let seenRouted = false;
let end = null;
for (const el of effectsContainer.children) {
// The dragged card is out of flow and the gap is what we're
// placing, so neither is a landmark
if (el === cardDrag.card || el === cardDrag.gap)
continue;
if (!isRoutedCard(el)) {
// The first thing past the chain is where a card dragged
// below the last one lands
if (seenRouted && end === null)
end = el;
continue;
}
seenRouted = true;
const r = el.getBoundingClientRect();
if (y < r.top + r.height / 2)
return el;
}
// The only effect in the chain has nowhere to go, so leave the gap
// where it already is rather than sending it to the bottom
if (!seenRouted)
return cardDrag.gap.nextSibling;
return end;
}
// Take the card out of the flow and leave a gap the same size behind it,
// so it can follow the pointer instead of only jumping between slots
// when you cross a midpoint. The gap is what actually gets moved around
// the list, and it's also where the card goes back when you let go.
function dragLift() {
const r = cardDrag.card.getBoundingClientRect();
cardDrag.gap = document.createElement('div');
cardDrag.gap.className = 'drag-placeholder';
cardDrag.gap.style.height = r.height + 'px';
effectsContainer.insertBefore(cardDrag.gap, cardDrag.card);
cardDrag.grabX = cardDrag.x - r.left;
cardDrag.grabY = cardDrag.y - r.top;
cardDrag.card.style.width = r.width + 'px';
cardDrag.card.classList.add('dragging');
dragFollow();
}
// The card is position:fixed, so these are plain viewport coordinates -
// which is also why auto-scrolling slides the list underneath without
// the card drifting away from the pointer.
function dragFollow() {
cardDrag.card.style.left = (cardDrag.x - cardDrag.grabX) + 'px';
cardDrag.card.style.top = (cardDrag.y - cardDrag.grabY) + 'px';
}
function dragReposition() {
const ref = dragInsertionRef(cardDrag.y);
if (ref !== cardDrag.gap.nextSibling)
effectsContainer.insertBefore(cardDrag.gap, ref);
}
function dragDrop() {
cardDrag.card.classList.remove('dragging');
cardDrag.card.style.width = '';
cardDrag.card.style.left = '';
cardDrag.card.style.top = '';
effectsContainer.insertBefore(cardDrag.card, cardDrag.gap);
cardDrag.gap.remove();
}
// Keep scrolling while the pointer is held near an edge, which matters
// on a phone where the list is a lot taller than the screen. Has to be
// on a timer rather than driven by pointermove, or holding still at the
// edge would stop it.
function dragScrollTick() {
if (!cardDrag || !cardDrag.moved) {
dragScrollRaf = 0;
return;
}
const h = window.innerHeight;
let dy = 0;
if (cardDrag.y < DRAG_EDGE)
dy = -DRAG_SCROLL_MAX * (1 - cardDrag.y / DRAG_EDGE);
else if (cardDrag.y > h - DRAG_EDGE)
dy = DRAG_SCROLL_MAX * (1 - (h - cardDrag.y) / DRAG_EDGE);
if (dy) {
window.scrollBy(0, dy);
dragReposition();
}
dragScrollRaf = requestAnimationFrame(dragScrollTick);
}
function cardDragStart(card, grip, e) {
if (!e.isPrimary || cardDrag)
return;
// The header carries controls of its own. A press that lands on one
// of those belongs to it, not to a drag.
if (e.target.closest('.collapse-chevron, .action-btn'))
return;
//
// A finger starts a drag on the handle and nowhere else, so the rest
// of the header is still somewhere the page can be scrolled from.
// The handle is the only thing in the card with touch-action: none,
// and this is the half of that rule that lives in script - see
// .drag-handle in the stylesheet for the other half and for why.
//
if (e.pointerType !== 'mouse' && !e.target.closest('.drag-handle'))
return;
//
// Capture stops the browser reinterpreting the gesture - a touch
// that wanders off the header would otherwise become a scroll - but
// don't rely on it lasting. Repositioning the card moves the header
// along with it, and moving the element that holds a capture
// releases the capture, after which pointerup goes to whatever is
// under the pointer instead of to us.
//
// So take the capture for the browser's benefit, and listen on the
// window for ours.
//
try {
grip.setPointerCapture(e.pointerId);
} catch (err) {
/* not fatal, the window listeners are what matter */
}
// Suppress selection from the press, not from the drag. Waiting for
// the threshold is too late - a phone has put the selection UI up
// by then. The header itself is user-select: none in the stylesheet;
// this covers everything the pointer travels over afterwards.
document.body.style.userSelect = 'none';
cardDrag = { card, grip, id: e.pointerId, gap: null,
startY: e.clientY, x: e.clientX, y: e.clientY, moved: false };
window.addEventListener('pointermove', cardDragMove);
window.addEventListener('pointerup', cardDragEnd);
window.addEventListener('pointercancel', cardDragEnd);
}
function cardDragMove(e) {
if (!cardDrag || e.pointerId !== cardDrag.id)
return;
cardDrag.x = e.clientX;
cardDrag.y = e.clientY;
if (!cardDrag.moved) {
// A press that never moves is a press, not a drag - otherwise
// just clicking the handle would send a routing update.
if (Math.abs(e.clientY - cardDrag.startY) < DRAG_THRESHOLD)
return;
cardDrag.moved = true;
dragLift();
if (!dragScrollRaf)
dragScrollRaf = requestAnimationFrame(dragScrollTick);
}
dragFollow();
dragReposition();
}
function cardDragEnd(e) {
if (!cardDrag || e.pointerId !== cardDrag.id)
return;
const moved = cardDrag.moved;
window.removeEventListener('pointermove', cardDragMove);
window.removeEventListener('pointerup', cardDragEnd);
window.removeEventListener('pointercancel', cardDragEnd);
if (moved)
dragDrop();
cardDrag = null;
document.body.style.userSelect = '';
if (moved)
setRouting(routingFromDOM());
}
//
// Flicking a card out of the chain.
//
// The eject button does this too, and did it first. This exists because
// a card that can be dragged around looks like a thing you can move, and
// throwing something you are holding off to one side to be rid of it is
// what a hand reaches for before it reaches for a button.
//
// It only had room to exist once a finger stopped starting drags from
// the whole header. Vertical there scrolls the page and sideways is
// ours, which is what 'touch-action: pan-y' on the header says, and the
// page has nowhere to go sideways so claiming it costs nothing.
//
// Touch only. A mouse still grabs the header to reorder, and the same
// gesture cannot also mean discard.
//
const SWIPE_ARM = 12; // px sideways before this is a swipe at all
const SWIPE_COMMIT = 70; // px, or a quarter of the card, to let go
const SWIPE_RETURN_MS = 180; // spring-back, and .swipe-return in the css
let cardSwipe = null;
// How far it has to go before letting go means it. A quarter of the card
// on a wide screen, and a thumb's worth on a narrow one.
function swipeCommit(card) {
return Math.max(SWIPE_COMMIT, card.offsetWidth / 4);
}
function cardSwipeStart(card, e) {
if (!e.isPrimary || cardSwipe || cardDrag)
return;
// Reordering is the mouse's gesture on this header - see cardDragStart()
if (e.pointerType === 'mouse')
return;
// The handle reorders, and the header's own controls keep their presses
if (e.target.closest('.drag-handle, .collapse-chevron, .action-btn'))
return;
cardSwipe = { card, id: e.pointerId,
x: e.clientX, y: e.clientY, armed: false };
window.addEventListener('pointermove', cardSwipeMove);
window.addEventListener('pointerup', cardSwipeEnd);
window.addEventListener('pointercancel', cardSwipeRelease);
}
function cardSwipeMove(e) {
if (!cardSwipe || e.pointerId !== cardSwipe.id)
return;
const dx = e.clientX - cardSwipe.x;
const dy = e.clientY - cardSwipe.y;
if (!cardSwipe.armed) {
//
// Sideways, and more sideways than not. The second half is what
// keeps a scroll that starts with a wobble from arming this: the
// browser is about to take the gesture for panning, and it says
// so with a pointercancel, but it is worth not having moved the
// card in the meantime.
//
if (Math.abs(dx) < SWIPE_ARM || Math.abs(dx) < Math.abs(dy))
return;
cardSwipe.armed = true;
cardSwipe.card.classList.remove('swipe-return');
}
//
// Fades as it goes, so how far is left to go is visible without
// anything having to be drawn behind it.
//
const gone = Math.min(1, Math.abs(dx) / swipeCommit(cardSwipe.card));
cardSwipe.card.style.transform = `translateX(${dx}px)`;
cardSwipe.card.style.opacity = 1 - 0.6 * gone;
}
//
// Put the card back where it belongs, whatever happens next.
//
// Cards are reused rather than rebuilt, so one let go halfway would
// otherwise still be sitting at that offset when it is routed again.
//
function cardSwipeRelease() {
const swipe = cardSwipe;
cardSwipe = null;
window.removeEventListener('pointermove', cardSwipeMove);
window.removeEventListener('pointerup', cardSwipeEnd);
window.removeEventListener('pointercancel', cardSwipeRelease);
if (!swipe || !swipe.armed)
return swipe;
//
// Taken off again once it has landed. The class names a transition,
// and .effect-card already has one it cares about - the attention
// glow, which is deliberately instant in one direction - so this is
// not something to leave sitting on a card afterwards.
//
swipe.card.classList.add('swipe-return');
swipe.card.style.transform = '';
swipe.card.style.opacity = '';
setTimeout(() => swipe.card.classList.remove('swipe-return'),
SWIPE_RETURN_MS);
return swipe;
}
function cardSwipeEnd(e) {
const swipe = cardSwipeRelease();
if (!swipe || e.pointerId !== swipe.id || !swipe.armed)
return;
// Not far enough: it has already sprung back
if (Math.abs(e.clientX - swipe.x) < swipeCommit(swipe.card))
return;
//
// Unrouting parks the card, so the spring-back the line above set
// going never gets a frame to be seen in - the card is gone before
// it can travel. Which is the intent: it went the way it was thrown.
//
unrouteEffect(parseInt(swipe.card.dataset.effectId));
}
//
// A tap, as opposed to a touch.
//
// These are two different things and the difference is the whole bug:
// this used to open the panel from 'touchstart', which is the moment a
// finger lands and before anybody - the browser included - knows what
// the gesture is going to be. So scrolling the page with a finger that
// happened to start on a pot threw a modal up over what you were
// scrolling towards, every time.
//
// A tap is a press that never goes anywhere. That can only be known at
// the end, so the decision is made on pointerup, and a gesture the
// browser takes over for scrolling comes back as pointercancel, which is
// exactly the answer we want: not a tap.
//
// "Never went anywhere" has to be remembered rather than measured at the
// end. Comparing where the finger landed against where it left is not
// the same question, and gets the interesting case backwards: drag a
// slider up and back down and you release within a few px of where you
// started, having very much moved. That is not a hypothetical - it is
// the ordinary way to use a slider, and it put the panel up on top of
// the value you had just finished setting.
//
// So the flag is sticky, exactly like cardDrag.moved next door: once
// this gesture has moved, it is not a tap again.
//
const TAP_SLOP = 10; // px of travel a tap is allowed
let potTap = null;
function releasePotTap() {
potTap = null;
window.removeEventListener('pointermove', movePotTap);
window.removeEventListener('pointerup', endPotTap);
window.removeEventListener('pointercancel', releasePotTap);
}
function movePotTap(e) {
if (!potTap || e.pointerId !== potTap.id)
return;
if (Math.abs(e.clientX - potTap.x) > TAP_SLOP ||
Math.abs(e.clientY - potTap.y) > TAP_SLOP)
potTap.moved = true;
}
function endPotTap(e) {
const tap = potTap;
releasePotTap();
if (!tap || e.pointerId !== tap.id)
return;
// It travelled: a scroll, or a drag of the control itself
if (tap.moved)
return;
tap.open();
}
//
// Open something when this element is tapped, without stealing a scroll
// that happens to begin on it.
//
// 'grab' is the part of it that a mouse can already operate directly -
// the inline slider - and a mouse press there is a drag of it rather
// than a request to open anything. A mouse has no scroll gesture to be
// confused with, so it does not wait for the release: anywhere else on
// the control opens immediately, which is how it always behaved.
//
function openOnTap(el, grab, open) {
el.addEventListener('pointerdown', (e) => {
if (!e.isPrimary)
return;
if (e.pointerType === 'mouse') {
if (e.target !== grab)
open();
return;
}
releasePotTap();
potTap = { id: e.pointerId, x: e.clientX, y: e.clientY,
moved: false, open };
window.addEventListener('pointermove', movePotTap);
window.addEventListener('pointerup', endPotTap);
window.addEventListener('pointercancel', releasePotTap);
});
}
//
// The chain, as an ordered list of effect ids. Kept up to date by
// applyRouting(), which is the one place routing becomes known - whether
// we decided it or the pedal told us.
//
// This is the model, and the cards are drawn from it. It used to be the
// other way around: routing was read back out of the dom by walking the
// cards and stopping at a divider, which made card position the truth and
// left routing, unrouting and reordering as three meanings for one
// gesture. That is what made the pool a long sortable list of things you
// were not doing anything with, and it is why the two anchor effects had
// to be recognised by name everywhere the list was walked.
//
let currentRouting = [];
// The firmware routes at most this many; the rest are silently dropped
const MAX_ROUTED = 14;
// Effect id -> its card, filled in by renderUI()
const effectCards = new Map();
// The unrouted pool, which renderUI() creates and applyRouting() fills
let effectPool = null;
//
// The two effects that are not in the routing order at all: the signal
// chain at the front, and the settings pseudo-effect at the back.
//
// Positional, because that is the firmware's own test - ROUTABLE_EFFECTS
// is the bits *between* the first entry and the last - and because
// anything else is a string that has to keep agreeing across two
// languages. A pedal older than a rename now puts its anchors in the
// right place and merely shows a stale label, which is honest: a stale
// label is what is running.
//
function isAnchorEffect(idx) {
return idx === 0 || idx === PEDAL_EFFECTS.length - 1;
}
//
// What the pedal says it is doing. See MIDI_CC_MAP.md for the wire
// format; effects.js has the numbers, taken from the firmware's midi.h.
//
// Attention is kept as the raw bits rather than applied as they arrive,
// because two CCs contribute to one picture: the chain pair covers the
// routed effects by position, and one bit of the global CC covers the
// Signal Chain, which is not in the chain to have a position. Re-render
// from both whenever either moves, or whenever the routing does.
//
let statusChainBits = 0;
let statusFrontAttn = false;
let pedalIdentity = null;
//
// Faults are shown for as long as the pedal says so - but never for less
// than this.
//
// The floor is on how long it stays up, not on how long we believe it,
// and the difference is the whole thing. The pedal reports state, on
// change and then periodically; a condition that persists is one message
// and then the same message again. A timer that expired would hide a
// clip that is still happening and then wait forever for a change that
// has no reason to come - which is exactly what it did.
//
// The floor exists because the other extreme is just as invisible: one
// clipped sample sets the bit for a single 40ms tick and clears it again,
// and nobody sees 40ms.
//
const STATUS_MIN_MS = 900;
const clipFault = { id: 'status-clip', on: false, since: 0, timer: null };
const dropFault = { id: 'status-drop', on: false, since: 0, timer: null };
// Neither of these is a transient: both come from the identity reply and
// are never timed out. boardFault means broken; earlyNote means old.
const boardFault = { id: 'status-board', on: false, since: 0, timer: null };
const earlyNote = { id: 'status-early', on: false, since: 0, timer: null };
const allFaults = [clipFault, dropFault, boardFault, earlyNote];
function paintFaults() {
for (const f of allFaults)
document.getElementById(f.id).classList.toggle('hidden', !f.on);
// The wrapper goes with them, so an idle header has nothing in it
document.getElementById('pedal-status')
.classList.toggle('hidden', !allFaults.some(f => f.on));
}
function setFault(f, on) {
if (on) {
// Still happening: cancel any pending hide and restart the floor
clearTimeout(f.timer);
f.timer = null;
f.on = true;
f.since = Date.now();
paintFaults();
return;
}
if (!f.on || f.timer)
return;
const left = STATUS_MIN_MS - (Date.now() - f.since);
if (left <= 0) {
f.on = false;
paintFaults();
} else {
f.timer = setTimeout(() => {
f.timer = null;
f.on = false;
paintFaults();
}, left);
}
}
function renderAttention() {
const lit = new Set();
if (statusFrontAttn) {
const front = PEDAL_EFFECTS.find(e => e.base === 'signal_chain');
if (front) lit.add(front.id);
}
currentRouting.forEach((effectId, pos) => {
if ((statusChainBits >> pos) & 1) lit.add(effectId);
});
// Every card, not just the lit ones - an effect that stops asking,
// or that leaves the chain while lit, has to go dark again.
PEDAL_EFFECTS.forEach((effect, idx) => {
const card = document.getElementById(`effect-${idx}`);
if (card) card.classList.toggle('attention', lit.has(effect.id));
});
}
//
// Forget everything the pedal told us about itself. A pedal that goes
// away stops reporting rather than reporting that it is fine, so its last
// word stays on the screen until somebody clears it.
//
function clearPedalStatus() {
for (const f of allFaults) {
clearTimeout(f.timer);
f.timer = null;
f.on = false;
}
document.getElementById('identity-info').textContent = 'Not connected.';
paintFaults();
statusChainBits = 0;
statusFrontAttn = false;
renderAttention();
}
//
// The menu says which scene it is about to overwrite, so it has to be
// refreshed whenever the selection moves - including when the picker is
// rebuilt from the identity reply, which is why this is not tucked inside
// the setup that binds it.
//
function updateSceneLabels() {
const sel = document.getElementById('global-scene-select');
const scene = sel ? sel.value : '0';
const saveBtn = document.getElementById('global-save-scene-btn');
const loadBtn = document.getElementById('global-load-scene-btn');
if (saveBtn) saveBtn.innerHTML = `💾&nbsp;&nbsp;Save to Scene ${scene}`;
if (loadBtn) loadBtn.innerHTML = `📂&nbsp;&nbsp;Load Scene ${scene}`;
}
//
// The scene picker, sized by whatever the pedal reports.
//
// Rebuilt rather than adjusted, because reconnecting to a different pedal
// can change the count, and a stale option would offer a scene that
// cannot be loaded.
//
function populateScenePicker(count) {
const sel = document.getElementById('global-scene-select');
if (!sel)
return;
const was = sel.value;
sel.innerHTML = '';
for (let i = 0; i < count; i++) {
const opt = document.createElement('option');
opt.value = i;
opt.textContent = `Scene ${i}`;
sel.appendChild(opt);
}
if (was !== '' && Number(was) < count)
sel.value = was;
updateSceneLabels();
}
//
// Telemetry, polled while somebody is watching.
//
// The layout is append-only, so read the fields this app knows about and
// stop. A frame shorter than expected is firmware from before the tail
// existed; a longer one is firmware from after this app was written.
// Neither is an error, and neither means zero - so every field is fetched
// through a helper that can answer "absent", and an absent field is left
// out of the readout rather than shown as a dash.
//
const TELEMETRY_POLL_MS = 200;
let telemetryTimer = null;
// 127 is the saturation the pedal sends for silence
function formatDbfs(byte) {
return byte >= 127 ? '\u2212\u221e' : `\u2212${byte}`;
}
function handleTelemetry(data) {
// The whole message: F0 7D 0B <version> <fields...> F7
const at = (i) => (3 + i < data.length - 1 ? data[3 + i] : undefined);
if (at(0) === undefined)
return;
const inPeak = at(1), floor = at(2), out = at(3);
const gate = at(4), loadHi = at(5), loadLo = at(6);
const bits = [];
if (inPeak !== undefined) bits.push(`in ${formatDbfs(inPeak)} dB`);
if (floor !== undefined) bits.push(`floor ${formatDbfs(floor)} dB`);
if (out !== undefined) bits.push(`out ${formatDbfs(out)} dB`);
//
// The gate as how far it is holding the signal down, not as a
// percentage of a multiplier: "\u221218 dB" is the number you compare
// against Level, which is also in dB.
//
if (gate !== undefined) {
if (gate >= 127)
bits.push('gate open');
else if (gate === 0)
bits.push('gate closed');
else
bits.push(`gate \u2212${Math.round(-20 * Math.log10(gate / 127))} dB`);
}
//
// The audio core's share of the sample period, which is the one
// number here with a hard ceiling: at 100% there is no idle left and
// the pedal starts dropping samples.
//
// Fourteen bits from layout 2 - an MSB where the seven-bit value
// used to be, an LSB after it - and taken by presence rather than by
// version, which is how every field on this line is read. Older
// firmware simply has no LSB and gets the coarse reading.
//
// The decimal place is worth having and was not before. One
// seven-bit step is 0.8% of the sample period while a whole reverb
// is about 22%, so rounding to a percent threw away a tenth of the
// most expensive effect the pedal has - and the headroom left is
// exactly what somebody stacking effects is looking at this for.
//
if (loadHi !== undefined) {
const load = loadLo === undefined
? loadHi / 127
: ((loadHi << 7) | loadLo) / 16383;
bits.push(`cpu ${(load * 100).toFixed(1)}%`);
}
const el = document.getElementById('signal-chain-meters');
if (el) el.textContent = bits.join(' \u00b7 ');
}
//
// Only while there is a pedal and somebody is looking at the page. A
// backgrounded tab polling five times a second is MIDI traffic spent on
// meters nobody can see.
//
function updateTelemetryPolling() {
const want = !!midiOutput && document.visibilityState === 'visible';
if (want && !telemetryTimer) {
telemetryTimer = setInterval(() => sendSysex([SYSEX_CMD.TELEMETRY]),
TELEMETRY_POLL_MS);
} else if (!want && telemetryTimer) {
clearInterval(telemetryTimer);
telemetryTimer = null;
const el = document.getElementById('signal-chain-meters');
if (el) el.textContent = 'no readings yet';
}
}
document.addEventListener('visibilitychange', updateTelemetryPolling);
//
// What the pedal says it is. Asked once, on connect.
//
// The build stamp is the useful part day to day: it answers "is this
// running what I just built", which is a question that has wasted more
// than one evening - a schema from stale firmware renders perfectly and
// is simply about a different pedal.
//
// The probe results are the rare-but-serious part. A TAC5112 or an
// SH1106 answering means the board predates this firmware by a couple of
// generations and nothing is going to work properly, so that gets a chip
// in the header rather than a line in a dialog nobody has open.
//
function handleIdentity(id) {
pedalIdentity = id;
const found = id.found || {};
const notes = [`Firmware built ${id.build || 'unknown'}.`];
const wrong = [];
const early = [];
const scenes = id.scenes || 1;
//
// How many scenes there are is the pedal's to say, not ours to
// assume. A 2kbit eeprom holds one; the 64kbit part holds 32.
//
populateScenePicker(scenes);
notes.push(`${scenes} scene${scenes === 1 ? '' : 's'}` +
`, hardware MIDI ${id.midi_hw ? 'on' : 'off'}.`);
//
// An early board is not a fault. Those play: the pedal sets the
// TAC5112 up over i2c, and the eeprom geometry is worked out rather
// than compiled in, so one scene on a 2kbit part saves and loads.
// Mono, because they never routed the second channel. Worth knowing
// which board is on the bench, not worth alarming anybody.
//
if (found.legacy_codec)
early.push('A TAC5112 codec answered on i2c, so this is an early ' +
'board: mono only, and the codec is set up over i2c ' +
'rather than strapped.');
if (found.legacy_screen)
early.push('An SH1106 screen answered on i2c, from a generation ' +
'that had one. Nothing drives it.');
boardFault.on = wrong.length > 0;
earlyNote.on = early.length > 0;
document.getElementById('status-board').title = wrong.join(' ');
document.getElementById('status-early').title = early.join(' ');
paintFaults();
document.getElementById('identity-info').textContent =
notes.concat(wrong, early).join(' ');
}
//
// Bench tool, on window so it can be driven from the console.
//
window.dumpEeprom = function (blk = 0, count = 1) {
for (let i = 0; i < count; i++)
setTimeout(() => sendSysex([0x0e, blk + i]), i * 50);
};
//
// Send the whole table. Nothing optimistic happens here: the pedal
// drops rules it does not like and echoes back what it kept, and that
// is what redraws. So the screen shows what the pedal has rather than
// what it was asked for, and a rule that did not survive is visible as
// a row that did not come back.
//
function sendRules(list, level = RULES_SCENE) {
const out = [SYSEX_SET_BINDING, level];
list.forEach(r => out.push(r.control & 0x7f, r.action & 0x7f,
r.effect & 0x7f, (r.pot || 0) & 0x7f,
(r.val[0] || 0) & 0x7f,
(r.val[1] || 0) & 0x7f));
sendSysex(out);
}
// Replace one rule, or drop it when 'r' is null.
//
// Make this control the scene's before changing it.
//
// Resolution is per control and the most specific level wins outright,
// so the moment a scene says anything about a control it says
// everything. A scene that inherits four rules and wants to change one
// therefore has to take all four, or the other three vanish.
//
function promoteControl(c) {
if (pedalRules.some(r => r.control === c))
return pedalRules;
return pedalRules.concat(
rulesByLevel[RULES_EFFECTIVE].filter(r => r.control === c));
}
function putRule(i, r) {
if (i < 0) {
// An inherited row: take the control first, then edit the copy.
const list = promoteControl(r.control);
const at = list.findIndex(x => x.control === r.control);
if (r)
list[at] = r;
sendRules(list);
return;
}
const list = pedalRules.slice();
if (r)
list[i] = r;
else
list.splice(i, 1);
sendRules(list);
}
function potDefFor(effId, potIdx) {
if (potIdx === 0)
return MIX_POT_DEF;
const e = PEDAL_EFFECTS[effectIdMap.get(effId)];
return e && e.pots ? e.pots[potIdx - 1] : null;
}
function potLabel(effId, potIdx) {
const pot = potDefFor(effId, potIdx);
const e = PEDAL_EFFECTS[effectIdMap.get(effId)];
if (!e || !pot || !pot.name)
return `effect ${effId}, parameter ${potIdx}`;
return `${e.name} \u2013 ${pot.name}`;
}
//
// Every (effect, pot) worth pointing something at, in the order the
// signal travels: the front anchor, the routed chain, the back anchor.
//
// An unrouted effect is left out because pointing at one would be
// pointing at nothing - the pedal does not step an effect that is not
// in the chain, and unrouting one throws its values away, so the value
// being set is about to be overwritten by a default. The two anchors
// are always in even though neither is in effect_chain[]: the Signal
// Chain always runs, and Settings is where the attention brightness
// lives, which is the one setting whose whole point is to be adjusted
// from the pedal while watching the LED.
//
function potTargets() {
const out = [];
const seen = new Set();
const add = (id) => {
const idx = effectIdMap.get(id);
const e = PEDAL_EFFECTS[idx];
if (!e || seen.has(id))
return;
seen.add(id);
//
// The mix first, because turning an effect off by taking its
// mix to nothing is the most useful thing a footswitch can do
// to one. The anchors have no wet and no dry, so no mix.
//
if (!isAnchorEffect(idx))
out.push({ effId: e.id, pot: 0, label: potLabel(e.id, 0) });
e.pots.forEach((pot, i) => {
if (pot.name)
out.push({ effId: e.id, pot: i + 1,
label: potLabel(e.id, i + 1) });
});
};
if (PEDAL_EFFECTS.length)
add(PEDAL_EFFECTS[0].id);
currentRouting.forEach(add);
if (PEDAL_EFFECTS.length > 1)
add(PEDAL_EFFECTS[PEDAL_EFFECTS.length - 1].id);
return out;
}
function sceneCount() {
const sel = document.getElementById('global-scene-select');
return sel && sel.options.length ? sel.options.length : 1;
}
function bindingSummary(b) {
const def = actionDef(b.action);
if (b.action === ACT.SCENE)
return `Scene ${b.effect}`;
if (!def.target)
return def.label;
if (b.effect === BIND_FOLLOW)
return `${def.label}: the knob\u2019s`;
return `${def.label}: ${potLabel(b.effect, b.pot)}`;
}
//
// One number, drawn the way that pot's own control is drawn: a menu for
// something enumerated, a slider and a readout for anything else.
//
// It has to be the pot's own units. A raw 0-120 would make "toggle
// between 40 and 80" unanswerable without doing the arithmetic that the
// rest of the app exists to avoid.
//
function valueControl(pot, val, onChange) {
const wrap = document.createElement('div');
wrap.className = 'binding-value';
if (pot && pot.curve === 'ENUM' && pot.enum && pot.enum.length) {
const sel = document.createElement('select');
sel.className = 'menu-select';
pot.enum.forEach((name, i) => {
const o = document.createElement('option');
o.value = i;
o.textContent = name;
sel.appendChild(o);
});
sel.value = Math.min(val, pot.enum.length - 1);
sel.addEventListener('change', () => onChange(parseInt(sel.value)));
wrap.appendChild(sel);
return wrap;
}
const range = document.createElement('input');
range.type = 'range';
range.min = 0;
range.max = 120;
range.value = val;
const out = document.createElement('span');
out.className = 'pot-value';
const show = (v) => out.textContent = pot ? formatPotValue(pot, v) : v;
show(val);
// Follow the drag, but only send when it is let go of.
range.addEventListener('input', () => show(parseInt(range.value)));
range.addEventListener('change', () => onChange(parseInt(range.value)));
wrap.appendChild(range);
wrap.appendChild(out);
return wrap;
}
//
// A sensible rule to start from when a gesture gains one.
//
// A click gets a mix toggle on the first thing in the chain, because
// that is overwhelmingly what a footswitch is added for, and an empty
// row you then have to fill in twice is not a starting point.
//
function newRule(ctrl) {
const t = potTargets()[0];
if (CONTROLS[ctrl].turns)
return { control: ctrl, action: ACT.POT,
effect: t ? t.effId : 0, pot: t ? t.pot : 0, val: [0, 0] };
return { control: ctrl, action: ACT.TOGGLE_POT,
effect: t ? t.effId : 0, pot: t ? t.pot : 0, val: [0, 120] };
}
function renderRule(r, i) {
const def = actionDef(r.action);
const row = document.createElement('div');
row.className = 'rule-row';
//
// Everything the rule says goes in the body, which wraps onto a
// second line when there are values. The remove button sits beside
// the whole thing rather than in the flow, because it belongs to
// the rule and not to any one line of it.
//
const body = document.createElement('div');
body.className = 'rule-body';
if (r.action === ACT.SCENE || def.target)
body.classList.add('has-target');
row.appendChild(body);
const act = document.createElement('select');
act.className = 'menu-select';
actionsFor(r.control).forEach(a => {
const o = document.createElement('option');
o.value = a.v;
o.textContent = a.label;
act.appendChild(o);
});
act.value = r.action;
body.appendChild(act);
act.addEventListener('change', () => {
const a = parseInt(act.value);
const nd = actionDef(a);
const next = { control: r.control, action: a,
effect: 0, pot: 0, val: [0, 0] };
//
// A new action inherits the target when it can still use one,
// because changing "reset this" to "toggle this" and being
// handed a different parameter would be a surprise. What it
// cannot inherit is a following target, which only reset may
// have.
//
if (a === ACT.SCENE) {
next.effect = (r.action === ACT.SCENE) ? r.effect : 0;
} else if (nd.target) {
const usable = r.effect !== BIND_FOLLOW || nd.follow;
if (def.target && usable) {
next.effect = r.effect;
next.pot = r.pot;
} else if (nd.follow) {
next.effect = BIND_FOLLOW;
} else {
const t = potTargets()[0];
if (t) { next.effect = t.effId; next.pot = t.pot; }
}
if (nd.values && next.effect !== BIND_FOLLOW)
next.val = [r.val[0], r.val[1]];
}
putRule(i, next);
});
if (r.action === ACT.SCENE || def.target) {
const arg = document.createElement('select');
arg.className = 'menu-select';
if (r.action === ACT.SCENE) {
for (let sc = 0; sc < sceneCount(); sc++) {
const o = document.createElement('option');
o.value = `s${sc}`;
o.textContent = `Scene ${sc}`;
arg.appendChild(o);
}
arg.value = `s${r.effect}`;
} else {
if (def.follow) {
const o = document.createElement('option');
o.value = 'follow';
o.textContent = 'The knob\u2019s parameter';
arg.appendChild(o);
}
const targets = potTargets();
const cur = `${r.effect}:${r.pot}`;
//
// Whatever it points at stays in the list even after that
// effect leaves the chain. Dropping it would make the
// select show some other parameter's name while the pedal
// went on driving this one.
//
if (r.effect !== BIND_FOLLOW &&
!targets.some(t => `${t.effId}:${t.pot}` === cur))
targets.unshift({ effId: r.effect, pot: r.pot,
label: potLabel(r.effect, r.pot) +
' (not routed)' });
targets.forEach(t => {
const o = document.createElement('option');
o.value = `${t.effId}:${t.pot}`;
o.textContent = t.label;
arg.appendChild(o);
});
arg.value = r.effect === BIND_FOLLOW ? 'follow' : cur;
}
arg.addEventListener('change', () => {
const v = arg.value;
const next = { ...r, val: r.val.slice() };
if (v.startsWith('s'))
next.effect = parseInt(v.slice(1));
else if (v === 'follow') {
next.effect = BIND_FOLLOW;
next.pot = 0;
} else {
const [e, p] = v.split(':').map(Number);
next.effect = e;
next.pot = p;
}
putRule(i, next);
});
body.appendChild(arg);
}
if (def.values && r.effect !== BIND_FOLLOW) {
const pot = potDefFor(r.effect, r.pot);
for (let v = 0; v < def.values; v++) {
body.appendChild(valueControl(pot, r.val[v], (nv) => {
const next = { ...r, val: r.val.slice() };
next.val[v] = nv;
putRule(i, next);
}));
}
}
const del = document.createElement('button');
del.className = 'action-btn rule-remove';
del.textContent = '\u00d7';
del.title = 'Remove';
del.addEventListener('click', () => putRule(i, null));
row.appendChild(del);
return row;
}
function renderBindings() {
const host = document.getElementById('bindings-rows');
const hint = document.getElementById('bindings-hint');
if (hint)
hint.textContent = haveRules
? 'What each control does now. Rules belong to the scene and ' +
'are kept when you save it; a control this scene says ' +
'nothing about is shown greyed, inherited from the ' +
'pedal-wide rules or from the built-in defaults. Editing ' +
'one makes it this scene\u0027s. A gesture can have more ' +
'than one rule and they all happen together \u2014 which is ' +
'how one press swaps between two effects.'
: 'Not connected.';
if (!host)
return;
host.innerHTML = '';
//
// Rows only once the pedal has told us what it has. An empty table
// here would claim every control does nothing, which is a specific
// and wrong answer to a question we have not asked yet.
//
if (!haveRules || !PEDAL_EFFECTS.length)
return;
CONTROLS.forEach((ctrl, c) => {
const group = document.createElement('div');
group.className = 'rule-group';
//
// The gesture's name, and adding to it, on one line - so that
// the button lands in the same column as the removes below it
// and it is obvious which gesture it adds to.
//
const head = document.createElement('div');
head.className = 'rule-head';
const label = document.createElement('label');
label.textContent = ctrl.name;
head.appendChild(label);
const add = document.createElement('button');
add.className = 'action-btn rule-add';
add.textContent = '+';
add.title = `Add a rule to ${ctrl.name}`;
add.disabled = pedalRules.length >= 16;
add.addEventListener('click', () =>
sendRules(promoteControl(c).concat([newRule(c)])));
head.appendChild(add);
group.appendChild(head);
//
// The effective table, not the scene's. A scene that says
// nothing about a control still has that control doing
// something - the pedal-wide rules or the built-in defaults -
// and drawing an empty row would claim it does nothing, which
// is a specific and wrong answer.
//
// Inherited rows carry no index into the scene's list because
// they are not in it. editing one promotes it first; see
// promoteControl().
//
const own = pedalRules.filter(r => r.control === c);
const shown = own.length ? own
: rulesByLevel[RULES_EFFECTIVE]
.filter(r => r.control === c);
shown.forEach(r => {
const i = pedalRules.indexOf(r);
const row = renderRule(r, i);
if (i < 0)
row.classList.add('inherited');
group.appendChild(row);
});
host.appendChild(group);
});
}
//
// What the knob is on, said next to the pot you are looking at.
//
// Without this the shortcut below is a button that silently steals the
// knob from wherever it was - and with one knob, every assignment is a
// theft from something.
//
function renderKnobHint() {
const hintEl = document.getElementById('knob-target-hint');
const btn = document.getElementById('assign-knob-btn');
if (!hintEl || !btn)
return;
const b = pedalRules.find(r => r.control === 0 && r.action === ACT.POT);
const here = activePotTarget();
if (here && b && b.effect === here.effId && b.pot === here.pot) {
hintEl.textContent = 'The knob is on this.';
btn.disabled = true;
} else {
hintEl.textContent = b
? `Knob is on ${potLabel(b.effect, b.pot)}.`
: 'The knob drives nothing.';
btn.disabled = !here;
}
}
// Which (effect, parameter) the open panel is showing, mix included.
function activePotTarget() {
if (!activePotCc)
return null;
const parts = activePotCc.split('-');
const eff = PEDAL_EFFECTS[parseInt(parts[1])];
if (!eff)
return null;
if (parts.length >= 4 && parts[2] === 'pot')
return { effId: eff.id, pot: parseInt(parts[3]) + 1 };
if (parts.length === 3 && parts[2] === 'mix')
return { effId: eff.id, pot: 0 };
return null;
}
function handleGlobalStatus(val) {
const dropped = val & STATUS_DROPPED_MASK;
statusFrontAttn = (val & STATUS_FRONT_ATTN) !== 0;
renderAttention();
setFault(clipFault, (val & STATUS_CLIPPED) !== 0);
// Only relabel while there is something to say, so the count doesn't
// change under a reading that is still being held up
if (dropped) {
// Saturated, so say so rather than claiming it was exactly 31
document.getElementById(dropFault.id).textContent =
dropped === STATUS_DROPPED_MASK ? `DROP ${dropped}+`
: (dropped > 1 ? `DROP ${dropped}` : 'DROP');
}
setFault(dropFault, dropped !== 0);
}
//
// An effect that isn't routed has no values: it goes back to the
// defaults from the schema.
//
// We do that here, in the app, rather than letting the pedal reset and
// then asking it what happened. Going and fetching the state back is a
// round trip we would have to race against, and the answer arrives in a
// couple of hundred SysEx messages - so the sliders end up showing the
// old values for a while, or a mix of old and new. Doing it locally and
// pushing the result means the UI is right by construction.
//
// Only effects that just *left* the chain are touched. Rewriting every
// unrouted effect on every reorder would be a flood for no reason.
//
function resetUnroutedEffects(newRouted) {
const wasRouted = new Set(currentRouting);
const nowRouted = new Set(newRouted);
PEDAL_EFFECTS.forEach((effect, idx) => {
if (!wasRouted.has(effect.id) || nowRouted.has(effect.id)) return;
effect.pots.forEach((potDef, pIdx) => {
const val = getInitialPotValue(potDef);
const el = ccToElementMap.get(`eff-${idx}-pot-${pIdx}`);
if (el) {
el.value = val;
const disp = el.parentElement.querySelector('.pot-value');
if (disp) disp.textContent = formatPotValue(potDef, val);
if (el.redrawCurve) el.redrawCurve();
}
sendSysex([SYSEX_CMD.PARAM_UPDATE, effect.id, pIdx + 1, val]);
});
const mixVal = Math.round((effect.defMix !== undefined ? effect.defMix : 1.0) * 120);
const mixEl = ccToElementMap.get(`eff-${idx}-mix`);
if (mixEl) {
mixEl.value = mixVal;
const disp = mixEl.parentElement.querySelector('.pot-value');
if (disp && mixEl.potDef) disp.textContent = formatPotValue(mixEl.potDef, mixVal);
}
sendSysex([SYSEX_CMD.PARAM_UPDATE, effect.id, 0, mixVal]);
});
}
//
// A routing change: tell the pedal, then draw what we asked for.
//
// Everything that changes the chain goes through here, so there is one
// place that sends ROUTING_ORDER and one place that decides what the
// cards look like afterwards.
//
function setRouting(ids) {
// Cap before the list is used for anything. Drawing the uncapped
// list would show effects in the chain that the pedal never heard
// about, since it drops the overflow without comment.
const routed = ids.slice(0, MAX_ROUTED);
sendSysex([SYSEX_CMD.ROUTING_ORDER, ...routed]);
// Before applyRouting(), which is what moves currentRouting on
resetUnroutedEffects(routed);
applyRouting(routed);
}
//
// Onto the end of the chain, which is where a new effect goes - and then
// go and look at it, because the end of the chain is somewhere else.
//
// Tapping a chip is asking for that effect, and what you get back is a
// card you cannot see: the pool is below the whole chain, the card lands
// at the far end of it, and everything shifts as the pool shrinks. So
// the one thing you asked for is the one thing not on the screen.
//
// Centred rather than just scrolled into view, because "just far enough"
// puts it hard against an edge with its controls half off - and the
// movement is worth having in its own right. It is what says where the
// effect went, which is a thing about the chain worth knowing.
//
function routeEffect(id) {
if (currentRouting.includes(id) || currentRouting.length >= MAX_ROUTED)
return;
setRouting([...currentRouting, id]);
showEffectCard(id);
}
function showEffectCard(id) {
const card = effectCards.get(id);
if (!card)
return;
// Somebody who has asked not to be moved about gets put there
// directly instead
const still = window.matchMedia &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
card.scrollIntoView({ block: 'center',
behavior: still ? 'auto' : 'smooth' });
}
function unrouteEffect(id) {
if (!currentRouting.includes(id))
return;
setRouting(currentRouting.filter(other => other !== id));
}
//
// After a drag, the cards are the order.
//
// The one place the dom is read rather than written, and it only reads
// the order of what is already in the chain: whether an effect is routed
// at all is decided by the buttons, never by where a card came to rest.
//
function routingFromDOM() {
const ids = [];
for (const el of effectsContainer.children) {
if (isRoutedCard(el))
ids.push(parseInt(el.dataset.effectId));
}
return ids;
}
function getInitialPotValue(pot) {
// The generator hands us the exact raw value the firmware has in its
// own table, so there is nothing to recompute and nothing to get
// subtly wrong. The rest of this is only a fallback for a pedal
// running a schema older than that field.
if (pot.defaultPot !== undefined) return pot.defaultPot;
if (pot.default === undefined) return 60;
return valueToPot(pot, pot.default);
}
//
// Collapsing a card.
//
// Expanding clears the inline display rather than setting one, so the
// stylesheet decides what the pot layout is. Naming a value here meant
// that a card which had been collapsed once came back as a flex row
// instead of the grid it started as.
//
function setCardCollapsed(card, collapsed) {
const controls = card.querySelector('.effect-controls');
const chevron = card.querySelector('.collapse-chevron');
if (controls) controls.style.display = collapsed ? 'none' : '';
if (chevron) chevron.style.transform = collapsed ? 'rotate(-90deg)' : 'rotate(0deg)';
}
//
// Draw the list from the routing.
//
// Cards for unrouted effects are parked rather than removed: the pedal
// still addresses their pots by id, ccToElementMap points straight at the
// inputs inside them, and rebuilding one on every routing change would
// invalidate both. They are hidden, and the pool speaks for them.
//
function applyRouting(routeIds) {
const wasRouted = new Set(currentRouting);
currentRouting = routeIds.slice();
// What the knob can be pointed at is a function of what is in the
// chain, so it changed just now too.
renderBindings();
renderKnobHint();
// The chain bits are by position, so what they mean just changed
renderAttention();
// Front anchor, then the chain in order, then the back anchor
const order = [];
if (PEDAL_EFFECTS.length)
order.push(PEDAL_EFFECTS[0].id);
routeIds.forEach(id => order.push(id));
if (PEDAL_EFFECTS.length > 1)
order.push(PEDAL_EFFECTS[PEDAL_EFFECTS.length - 1].id);
const placed = new Set(order);
order.forEach(id => {
const card = effectCards.get(id);
if (!card)
return;
effectsContainer.appendChild(card);
card.classList.remove('parked');
//
// Anything drawn from the card's own measurements was drawn
// blind while it was parked, because a hidden element has no
// size to measure. This is not a corner case: the pedal's state
// dump arrives before the routing order that ends it, so every
// card is parked for the whole of it.
//
const effect = PEDAL_EFFECTS[effectIdMap.get(id)];
if (effect && effect.redrawCurve)
effect.redrawCurve();
// Open it on the way in. An effect that has just been added is
// one you are about to set up - but only on the way in, or
// reordering the chain would keep reopening a card you closed.
if (!wasRouted.has(id) && !isAnchorEffect(effectIdMap.get(id)))
setCardCollapsed(card, false);
});
PEDAL_EFFECTS.forEach(effect => {
const card = effectCards.get(effect.id);
if (!card || placed.has(effect.id))
return;
effectsContainer.appendChild(card);
card.classList.add('parked');
});
if (effectPool) {
effectsContainer.appendChild(effectPool);
renderPool();
}
}
//
// The pool: everything routable that is not in the chain.
//
// A set, not a list. The firmware resets an effect the moment it leaves
// the chain, so an unrouted effect has no values and no position - there
// is nothing here to order and nothing to edit. Full cards said otherwise
// on both counts, offering a drag that was discarded on release and
// sliders whose values were thrown away by the next routing change.
//
// Chips are rebuilt from scratch each time because they hold nothing: a
// name and an id, both of which came from the schema.
//
function renderPool() {
if (!effectPool)
return;
effectPool.innerHTML = '';
const full = currentRouting.length >= MAX_ROUTED;
const chips = [];
PEDAL_EFFECTS.forEach((effect, idx) => {
if (isAnchorEffect(idx) || currentRouting.includes(effect.id))
return;
const chip = document.createElement('button');
chip.className = 'effect-chip';
chip.textContent = effect.name;
chip.dataset.effectId = effect.id;
chip.disabled = full;
chip.addEventListener('click', () => routeEffect(effect.id));
chips.push(chip);
});
// Nothing left out of the chain, so nothing to say
effectPool.classList.toggle('hidden', chips.length === 0);
if (!chips.length)
return;
const label = document.createElement('div');
label.className = 'pool-label';
label.textContent = full
? `Chain is full at ${MAX_ROUTED} — remove one to add another`
: 'Not in the chain — tap to add';
effectPool.appendChild(label);
const grid = document.createElement('div');
grid.className = 'pool-chips';
chips.forEach(chip => grid.appendChild(chip));
effectPool.appendChild(grid);
}
// Drag styling, kept next to the code that relies on it
const style = document.createElement('style');
style.textContent = `
.effect-card.dragging {
position: fixed;
z-index: 1000;
margin: 0;
pointer-events: none;
cursor: grabbing;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
}
.drag-placeholder {
box-sizing: border-box;
border: 2px dashed var(--primary);
border-radius: 12px;
opacity: 0.4;
}
`;
document.head.appendChild(style);
function renderUI() {
effectsContainer.innerHTML = '';
effectCards.clear();
effectPool = document.createElement('div');
effectPool.className = 'effect-pool';
effectPool.id = 'effect-pool';
PEDAL_EFFECTS.forEach((effect, idx) => {
const card = document.createElement('section');
card.className = 'glass-panel effect-card';
card.id = `effect-${idx}`;
// Header
const header = document.createElement('div');
header.className = 'effect-header';
card.dataset.effectId = effect.id;
const title = document.createElement('div');
title.className = 'effect-title';
title.style.display = 'flex';
title.style.alignItems = 'center';
// The two anchors are not in the chain, so there is nothing to
// reorder them relative to and no handle on them
if (!isAnchorEffect(idx)) {
title.innerHTML = `<span class="drag-handle">≡</span>
<span class="collapse-chevron" style="cursor: pointer; margin-right: 8px; font-size: 0.8em; transition: transform 0.2s;">▼</span>
<span>${effect.name}</span>`;
// A drag starts on the header and nowhere else, so there is
// never any question of whether you meant the card or the
// slider you happen to be over - and the header is big
// enough to hit with a thumb, which the handle was not.
// The controls that sit in the header keep their own
// presses; cardDragStart() steps out of the way for them.
header.classList.add('draggable');
header.addEventListener('pointerdown',
(e) => cardDragStart(card, header, e));
// The other half of the header's job, and the two never
// both engage: one is the handle and a mouse, the other is
// a finger anywhere else
header.addEventListener('pointerdown',
(e) => cardSwipeStart(card, e));
} else {
title.innerHTML = `<span class="collapse-chevron" style="cursor: pointer; margin-right: 8px; font-size: 0.8em; transition: transform 0.2s;">▼</span>
<span>${effect.name}</span>`;
}
const enableGroup = document.createElement('div');
enableGroup.className = 'control-group enable-group';
enableGroup.innerHTML = `
<button class="action-btn effect-reset-btn" title="Reset to Defaults">↺</button>
`;
//
// Out of the chain, and back to being a chip. The counterpart of
// tapping the chip, and the reason neither direction needs a drag
// any more.
//
// cardDragStart() steps aside for anything with .action-btn on
// it, so this does not have to fight the header's drag.
//
if (!isAnchorEffect(idx)) {
const unrouteBtn = document.createElement('button');
unrouteBtn.className = 'action-btn effect-unroute-btn';
unrouteBtn.title = 'Remove from the chain';
unrouteBtn.textContent = '⏏';
unrouteBtn.addEventListener('click', (e) => {
e.stopPropagation();
unrouteEffect(effect.id);
});
enableGroup.appendChild(unrouteBtn);
}
header.appendChild(title);
header.appendChild(enableGroup);
card.appendChild(header);
// Collapse toggle
const chevron = title.querySelector('.collapse-chevron');
if (chevron) {
chevron.addEventListener('click', (e) => {
e.stopPropagation();
const controls = card.querySelector('.effect-controls');
setCardCollapsed(card, controls.style.display !== 'none');
});
}
// The Reset button resets all pots. We should also reset the Mix pot!
const resetBtn = enableGroup.querySelector('.effect-reset-btn');
resetBtn.addEventListener('click', (e) => {
e.stopPropagation();
effect.pots.forEach((potDef, pIdx) => {
const initialVal = getInitialPotValue(potDef);
const inputEl = ccToElementMap.get(`eff-${idx}-pot-${pIdx}`);
if (inputEl) {
inputEl.value = initialVal;
inputEl.dispatchEvent(new Event(inputEl.tagName === 'SELECT' ? 'change' : 'input'));
}
});
const mixEl = ccToElementMap.get(`eff-${idx}-mix`);
if (mixEl) {
const defaultMixVal = Math.round((effect.defMix !== undefined ? effect.defMix : 1.0) * 120);
mixEl.value = defaultMixVal;
mixEl.dispatchEvent(new Event('input'));
}
});
// Controls
const controls = document.createElement('div');
let slidersContainer = null;
let eqPotsInputs = [];
let eqFooter = null;
//
// Some effects are better drawn than listed, because frequency
// and gain per band is two-dimensional and a row of
// one-dimensional controls cannot say it.
//
// Which ones is not this code's business any more. The effect
// header declares its band list and the schema carries it, so
// the app asks what an effect is rather than which effect it is
// - it used to check the display name, and renaming that effect
// silently turned its curve into ten sliders.
//
// Bands take the pots in order, two each: frequency then gain.
//
const bands = (effect.graph || []).map((band, b) => ({
type: band.type, q: band.q, qPot: band.qPot,
freqPot: b * 2, gainPot: b * 2 + 1
}));
const isEq = bands.length > 0;
if (isEq) {
controls.className = 'effect-controls eq-container';
const curveWrapper = document.createElement('div');
curveWrapper.className = 'eq-curve-wrapper';
curveWrapper.innerHTML = `
<canvas id="eq-canvas-${idx}" width="1000" height="300" class="eq-canvas"></canvas>
`;
controls.appendChild(curveWrapper);
slidersContainer = document.createElement('div');
slidersContainer.className = 'eq-sliders';
controls.appendChild(slidersContainer);
//
// The row under the curve. The Mix control is the only pot
// this effect shows - the other ten are the graph - so it
// had the whole width to itself and did not need it.
//
eqFooter = document.createElement('div');
eqFooter.className = 'eq-footer';
controls.appendChild(eqFooter);
effect.redrawCurve = () => {
const canvas = curveWrapper.querySelector(`#eq-canvas-${idx}`);
//
// The pots are collected as the card is built, so an
// early redraw can arrive before they are all there.
// Two per band is how many it takes to draw one.
//
if (!canvas || eqPotsInputs.length < bands.length * 2) return;
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;
ctx.clearRect(0, 0, W, H);
//
// How many canvas units there are to a screen pixel, for
// the things that should be a fixed size to the eye.
//
// A parked card has no layout and so no width at all.
// Falling back to 1 keeps this finite; applyRouting()
// redraws when a card is unparked, which is what makes
// the fallback temporary rather than wrong - a card is
// parked for the whole of the pedal's state dump, so
// this is the ordinary case at startup and not a corner.
//
const rect = canvas.getBoundingClientRect();
const scaleX = rect.width ? W / rect.width : 1;
const scaleY = rect.height ? H / rect.height : 1;
// Math helper for biquad mag sq
function fastsincos(f) {
const rad = f * 2 * Math.PI;
return { sin: Math.sin(rad), cos: Math.cos(rad) };
}
function pow2(x) { return Math.pow(2, x); }
function biquad_mag_sq(c, w0, w2) {
const re_num = c.b0 + c.b1 * w0.cos + c.b2 * w2.cos;
const im_num = c.b1 * w0.sin + c.b2 * w2.sin;
const num = re_num * re_num + im_num * im_num;
const re_den = 1.0 + c.a1 * w0.cos + c.a2 * w2.cos;
const im_den = c.a1 * w0.sin + c.a2 * w2.sin;
const den = re_den * re_den + im_den * im_den;
if (den < 1e-12) return num * 1e12;
return num / den;
}
function biquad_loshelf(w0, Q, A) {
const alpha = w0.sin / (2 * Q);
const ap1 = A + 1, am1 = A - 1;
const sqAmin2 = 2 * Math.sqrt(A) * alpha;
const a0_inv = 1 / (ap1 + am1 * w0.cos + sqAmin2);
return {
b0: A * (ap1 - am1 * w0.cos + sqAmin2) * a0_inv,
b1: 2 * A * (am1 - ap1 * w0.cos) * a0_inv,
b2: A * (ap1 - am1 * w0.cos - sqAmin2) * a0_inv,
a1: -2 * (am1 + ap1 * w0.cos) * a0_inv,
a2: (ap1 + am1 * w0.cos - sqAmin2) * a0_inv
};
}
function biquad_peaking(w0, Q, A) {
const alpha = w0.sin / (2 * Q);
const a0_inv = 1 / (1 + alpha / A);
return {
b0: (1 + alpha * A) * a0_inv,
b1: (-2 * w0.cos) * a0_inv,
b2: (1 - alpha * A) * a0_inv,
a1: (-2 * w0.cos) * a0_inv,
a2: (1 - alpha / A) * a0_inv
};
}
function biquad_hishelf(w0, Q, A) {
const alpha = w0.sin / (2 * Q);
const ap1 = A + 1, am1 = A - 1;
const sqAmin2 = 2 * Math.sqrt(A) * alpha;
const a0_inv = 1 / (ap1 - am1 * w0.cos + sqAmin2);
return {
b0: A * (ap1 + am1 * w0.cos + sqAmin2) * a0_inv,
b1: -2 * A * (am1 + ap1 * w0.cos) * a0_inv,
b2: A * (ap1 + am1 * w0.cos - sqAmin2) * a0_inv,
a1: 2 * (am1 - ap1 * w0.cos) * a0_inv,
a2: (ap1 - am1 * w0.cos - sqAmin2) * a0_inv
};
}
const pots = eqPotsInputs.map(el => parseInt(el.value));
const getFloat = (p_idx) => potToValue(effect.pots[p_idx], pots[p_idx]);
//
// A, not the gain: 10^(dB/40) is the square root of the
// linear gain, which is what the cookbook formulas above
// want and what they are handed here.
//
// The firmware's _biquad_peaking() takes the *gain* in
// that argument and square-roots it itself, so it is
// handed db_to_level(), which is 10^(dB/20). Same name,
// same shape, fourth argument in different units - the
// two agree, and they agree by arriving from opposite
// directions. Making them "consistent" without checking
// which one is which would double or halve every dB in
// the picture.
//
function peq_pot_A(db) { return Math.pow(10, db / 40.0); }
//
// Q comes from the band, not from here. It used to be
// 1.0 for everything the app drew while the effect ran
// whatever its own header said, so the curve was not the
// filter for anything that disagreed.
//
const fs = 48000;
const shape = { LOSHELF: biquad_loshelf, PEAKING: biquad_peaking,
HISHELF: biquad_hishelf };
// A band's Q is a fixed number, or a pot it can be
// dragged from while playing
const bandQ = (band) =>
band.qPot === undefined ? band.q : getFloat(band.qPot);
const coeff = bands.map((band) =>
shape[band.type](fastsincos(getFloat(band.freqPot) / fs),
bandQ(band), peq_pot_A(getFloat(band.gainPot))));
//
// The phase, behind everything, if anybody wants it.
//
// biquad_mag_sq() builds the same two complex numbers
// and throws their angles away, so this costs an atan2
// apiece and nothing else. The sign matters here where
// it did not there: z is exp(-jw), so the imaginary
// parts are negative, and a magnitude does not care.
//
// Wrapped to +-180 rather than unwrapped. Five sections
// can run through several turns and an unwrapped plot
// would rescale itself as you drag, which is exactly the
// sort of thing that pulls the eye away from the curve
// that matters. The cost is a jump at each wrap, so the
// path is broken there instead of drawn through.
//
function biquad_phase(c, w0, w2) {
const num = Math.atan2(-(c.b1 * w0.sin + c.b2 * w2.sin),
c.b0 + c.b1 * w0.cos + c.b2 * w2.cos);
const den = Math.atan2(-(c.a1 * w0.sin + c.a2 * w2.sin),
1 + c.a1 * w0.cos + c.a2 * w2.cos);
return num - den;
}
if (eqShowPhase) {
const phaseToY = (deg) => H / 2 * (1 - deg / 180);
let last = null;
ctx.beginPath();
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.16)';
for (let x = 0; x <= W; x += 2) {
let freq = xToFreq(x, W);
if (freq < 5) freq = 5;
if (freq > 24000) freq = 24000;
const w0 = fastsincos(freq / fs);
const w2 = fastsincos((2.0 * freq) / fs);
let rad = 0;
for (const c of coeff)
rad += biquad_phase(c, w0, w2);
const deg = ((rad * 180 / Math.PI + 180) % 360 + 360) % 360 - 180;
const y = phaseToY(deg);
// A wrap is a jump of half the plot or more, and
// is not a line the filter ever draws
if (last === null || Math.abs(deg - last) > 180)
ctx.moveTo(x, y);
else
ctx.lineTo(x, y);
last = deg;
}
ctx.stroke();
}
ctx.beginPath();
ctx.lineWidth = 4;
ctx.strokeStyle = '#4ecca3';
for (let x = 0; x <= W; x += 2) {
let freq = xToFreq(x, W);
// clamp freq for math stability outside margins
if (freq < 5) freq = 5;
if (freq > 24000) freq = 24000;
const w0 = fastsincos(freq / fs);
const w2 = fastsincos((2.0 * freq) / fs);
let mag_sq = 1.0;
for (const c of coeff)
mag_sq *= biquad_mag_sq(c, w0, w2);
let mag = Math.sqrt(mag_sq);
if (mag < 0.0001) mag = 0.0001;
const db = 20.0 * Math.log10(mag);
// map dB to Y [0..H]
let y = dbToY(db, H);
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Draw interactive nodes
nodes = [];
for (let i = 0; i < bands.length; i++) {
const freq = getFloat(bands[i].freqPot);
const db = getFloat(bands[i].gainPot);
const x = freqToX(freq, W);
const y = dbToY(db, H);
nodes.push({x, y});
//
// The node and its labels are drawn in screen px,
// not canvas units.
//
// The canvas is 1000 wide and 300 tall and is drawn
// at whatever width the card is, so the two axes are
// scaled by quite different amounts and a round arc
// is round at exactly one card width. On a phone
// the dot came out about 2.5px across and 8px tall,
// its outline was three times thinner sideways than
// it was top to bottom, and the labels were squashed
// to a third of their width.
//
// Scaling by exactly what the css then divides by
// leaves the net transform an identity, so anything
// drawn in here lands on the glass the size it says.
// It has to wrap the strokes and the text too: a
// line width is in user units like everything else,
// which is the whole reason the outline was wrong.
//
// The curve itself is deliberately left alone. It
// is a graph, and a graph is drawn in the units of
// the thing it is plotting. These are controls
// sitting on top of it, and a control's size is a
// claim about where it can be grabbed - so it wants
// to agree with the hit test, which is also in
// screen px. See EQ_GRAB_RADIUS.
//
const colour = (typeof activeNodeIdx !== 'undefined'
&& i === activeNodeIdx) ? '#ffffff' : '#4ecca3';
ctx.save();
ctx.translate(x, y);
ctx.scale(scaleX, scaleY);
//
// Which kind of band this is, said with a tick
// rather than a colour.
//
// A shelf runs flat away from its corner frequency
// and a peak does not, so a tick pointing the way
// the shelf acts - left for the low one, right for
// the high one - is a small picture of what the band
// does, and needs no legend to read. Colour would
// have needed one, and the fill is already spoken
// for: it says which node you have hold of.
//
// Drawn before the dot so the dot covers its root.
// Which way it points comes from the band's own
// kind, so an effect with a different arrangement of
// sections gets the right ticks without being known
// about here.
//
const shelf = bands[i].type === 'LOSHELF' ? -1
: bands[i].type === 'HISHELF' ? 1 : 0;
if (shelf) {
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(shelf * EQ_SHELF_TICK, 0);
ctx.lineCap = 'round';
//
// Stroked twice, for the dark edge the dot gets
// and for the same reason. A node is drawn in
// the curve's own colour, which is deliberate -
// the dot survives it by having an outline, and
// a bare tick did not: it lay along a flat
// stretch of curve and disappeared into it,
// which next to a shelf at 0dB is most of the
// time.
//
// Round caps so the far end is edged too, and
// the near end is under the dot either way.
//
ctx.lineWidth = EQ_SHELF_TICK_WIDTH + 2;
ctx.strokeStyle = '#1a1a2e';
ctx.stroke();
ctx.lineWidth = EQ_SHELF_TICK_WIDTH;
ctx.strokeStyle = colour;
ctx.stroke();
}
ctx.beginPath();
ctx.arc(0, 0, EQ_NODE_RADIUS, 0, 2 * Math.PI);
ctx.fillStyle = colour;
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = '#1a1a2e';
ctx.stroke();
// Draw labels
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
ctx.font = '12px "Inter", sans-serif';
ctx.textAlign = 'center';
ctx.fillText(formatFreqShort(freq), 0, -24);
ctx.fillText(`${db > 0 ? '+' : ''}${db.toFixed(1)}dB`, 0, -10);
ctx.restore();
}
};
// Interactive EQ logic
//
// Both in screen px. The dot is what you see and the radius
// is what you can hit, and a target wider than its dot is
// the normal arrangement - nothing else on this canvas is
// grabbable, so there is nothing for it to steal from, and
// the nearest node wins where two overlap.
//
const EQ_NODE_RADIUS = 7;
const EQ_GRAB_RADIUS = 30;
const EQ_SHELF_TICK = 14; // how far a shelf's tick reaches out
const EQ_SHELF_TICK_WIDTH = 3;
//
// How much of the gain axis the graph shows, either side of
// 0dB. More than the pots can reach, which is +-20.
//
// They used to be the same number, so a band at full gain
// put its node exactly on the canvas edge - the dot drawn
// half outside it and the two labels, which sit above the
// node, entirely outside it. The reading disappeared at
// precisely the setting you would want to read.
//
// Widening the axis rather than making the canvas taller.
// Taller costs vertical space on the screen that a phone
// does not have, and the picture does not want it: what is
// gained is headroom, which is empty by definition. This
// way +-20dB is the middle 71% of the height and the rest
// is room for the labels to live in. It also means the
// summed curve, which five bands can push well past 20dB,
// stays in the picture for longer.
//
const EQ_DB_HEADROOM = 8;
const EQ_DB_SPAN = EQ_DB_HEADROOM +
Math.max(...bands.map((b) => Math.max(Math.abs(effect.pots[b.gainPot].min),
Math.abs(effect.pots[b.gainPot].max))));
const dbToY = (db, H) => H / 2 - db * (H / (2 * EQ_DB_SPAN));
const yToDb = (y, H) => (H / 2 - y) * (2 * EQ_DB_SPAN) / H;
//
// The frequency axis, taken from the pots rather than
// written down a second time.
//
// The two have to span the same range or a band at the end
// of its travel sits off the end of the picture, and they
// were separately hardcoded at 20-20000 with nothing keeping
// them that way. The bands are the reason the graph exists,
// so they are what it should measure.
//
const EQ_MARGIN = 40;
const fMin = Math.min(...bands.map((b) => effect.pots[b.freqPot].min));
const fMax = Math.max(...bands.map((b) => effect.pots[b.freqPot].max));
const fSpan = Math.log2(fMax / fMin);
const freqToX = (f, W) =>
EQ_MARGIN + Math.log2(f / fMin) / fSpan * (W - 2 * EQ_MARGIN);
const xToFreq = (x, W) =>
fMin * Math.pow(2, (x - EQ_MARGIN) / (W - 2 * EQ_MARGIN) * fSpan);
const EQ_PICK_SLOP = 4; // px before a drag has a direction
let isDragging = false;
let dragPointerId = null;
let dragStartX = 0, dragStartY = 0;
let dragPile = null;
let activeNodeIdx = -1;
// The five band frequencies, as pot values - the app's live
// copy of what the pedal has
const freqPots = () =>
bands.map((b) => parseInt(eqPotsInputs[b.freqPot].value));
let nodes = [];
let lastEqUpdate = 0;
// Helper to inverse map freq/db to MIDI val (0-120)
function updateEqNode(nodeIdx, freq, db) {
const fIdx = bands[nodeIdx].freqPot;
const gIdx = bands[nodeIdx].gainPot;
const fDef = effect.pots[fIdx];
const gDef = effect.pots[gIdx];
// Off the end of the graph is off the end of the pot,
// which valueToPot() already answers by clamping
const gVal = valueToPot(gDef, db);
let fVal = valueToPot(fDef, freq);
if (eqKeepOrder)
fVal = clampToNeighbours(freqPots(), nodeIdx, fVal);
// Update inputs visually
eqPotsInputs[fIdx].value = fVal;
eqPotsInputs[gIdx].value = gVal;
// Throttle SysEx to max 50Hz (20ms) to prevent USB floods, but always send
const now = performance.now();
if (now - lastEqUpdate > 20) {
sendSysex([SYSEX_CMD.PARAM_UPDATE, effect.id, fIdx + 1, fVal]);
sendSysex([SYSEX_CMD.PARAM_UPDATE, effect.id, gIdx + 1, gVal]);
lastEqUpdate = now;
}
const fDisplay = eqPotsInputs[fIdx].parentElement.querySelector('.pot-value');
if (fDisplay) fDisplay.textContent = formatPotValue(fDef, fVal);
const gDisplay = eqPotsInputs[gIdx].parentElement.querySelector('.pot-value');
if (gDisplay) gDisplay.textContent = formatPotValue(gDef, gVal);
effect.redrawCurve();
}
//
// Where the pointer is, in canvas coordinates.
//
// Deliberately not clamped, and deliberately still answered
// when the pointer is outside the canvas: a drag that has
// left the graph is still a drag, and onMove() clamps what
// it does with this rather than refusing to hear it.
//
const getPointerPos = (e) => {
const canvas = curveWrapper.querySelector(`#eq-canvas-${idx}`);
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY,
scaleX, scaleY
};
};
const onDown = (e) => {
if (!e.isPrimary)
return;
e.preventDefault();
const pos = getPointerPos(e);
let minDist = Infinity;
activeNodeIdx = -1;
//
// How close is close enough, measured on the screen
// rather than on the canvas.
//
// The canvas is 1000 units wide and 300 tall, and it is
// drawn at whatever width the card is with the height
// fixed - so the two axes are scaled by quite different
// amounts, and a radius in canvas units is an ellipse on
// the glass. On a phone it came out about 12px wide and
// 40px tall: tightest on the axis the bands are spread
// along, on the device with the least precision, which
// is exactly backwards.
//
// Dividing by the scale asks the question in the units
// the finger is actually in, so the target is a circle
// and it is the same size everywhere.
//
nodes.forEach((n, i) => {
const dx = (n.x - pos.x) / pos.scaleX;
const dy = (n.y - pos.y) / pos.scaleY;
const dist = Math.hypot(dx, dy);
if (dist < EQ_GRAB_RADIUS && dist < minDist) {
minDist = dist;
activeNodeIdx = i;
}
});
if (activeNodeIdx !== -1) {
isDragging = true;
dragPointerId = e.pointerId;
dragStartX = e.clientX;
dragStartY = e.clientY;
//
// If bands are piled on top of each other, which
// one you meant depends on which way you are about
// to go, and that is not known yet. Remember the
// pile and settle it on the first real movement.
//
// The provisional pick stays highlighted meanwhile
// and it does not matter which of them it is: they
// are drawn in the same place, so the correction is
// invisible.
//
const pile = pileAt(freqPots(), activeNodeIdx);
dragPile = pile.length > 1 ? pile : null;
//
// The rest of the drag is heard on the window, not
// on the canvas. A node has limits and the pointer
// does not, so the two part company at the edge of
// the graph all the time - and while they are apart,
// every move event would go to whatever the pointer
// is over instead of to us.
//
// That is what used to end the drag: moves stopped
// arriving, and 'mouseleave' called onUp on the way
// out for good measure. The node stopped where it
// was and you had to find it and grab it again,
// which with a finger means finding it underneath
// the finger.
//
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
effect.redrawCurve();
}
};
const onMove = (e) => {
if (!isDragging || activeNodeIdx === -1) return;
if (e.pointerId !== dragPointerId) return;
e.preventDefault();
//
// Settle which of a pile was meant, once, on the first
// movement worth reading - and change nothing at all
// until then, so the answer cannot arrive after some
// other band has already been edited.
//
// Going left takes the lowest-numbered and going right
// the highest, which sounds like a convention and is
// not: under clamping those are the only two that can
// move at all. The rest of the pile is blocked by the
// one being picked. So this is "take the one that is
// free to go where you are going", and it peels a pile
// apart one band at a time.
//
// A mostly-vertical first move is a gain edit, where
// the order does not come into it, so the provisional
// pick stands.
//
if (dragPile) {
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
if (Math.abs(dx) < EQ_PICK_SLOP && Math.abs(dy) < EQ_PICK_SLOP)
return;
if (Math.abs(dx) >= Math.abs(dy))
activeNodeIdx = dx < 0 ? dragPile[0]
: dragPile[dragPile.length - 1];
dragPile = null;
}
const pos = getPointerPos(e);
const canvas = curveWrapper.querySelector(`#eq-canvas-${idx}`);
const W = canvas.width;
const H = canvas.height;
const posX = Math.max(0, Math.min(W, pos.x));
const posY = Math.max(0, Math.min(H, pos.y));
const freq = xToFreq(posX, W);
const db = yToDb(posY, H);
updateEqNode(activeNodeIdx, freq, db);
};
const onUp = (e) => {
if (e.pointerId !== dragPointerId)
return;
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
dragPointerId = null;
dragPile = null;
if (isDragging && activeNodeIdx !== -1) {
// Force final sysex flush on release
const fIdx = bands[activeNodeIdx].freqPot;
const gIdx = bands[activeNodeIdx].gainPot;
const fVal = parseInt(eqPotsInputs[fIdx].value);
const gVal = parseInt(eqPotsInputs[gIdx].value);
if (!isNaN(fVal)) sendSysex([SYSEX_CMD.PARAM_UPDATE, effect.id, fIdx + 1, fVal]);
if (!isNaN(gVal)) sendSysex([SYSEX_CMD.PARAM_UPDATE, effect.id, gIdx + 1, gVal]);
}
isDragging = false;
if (activeNodeIdx !== -1) {
activeNodeIdx = -1;
effect.redrawCurve();
}
};
// We must wait for the canvas to be added to DOM
setTimeout(() => {
const canvasEl = curveWrapper.querySelector(`#eq-canvas-${idx}`);
if (canvasEl) {
// Only the grab is on the canvas - see onDown() for
// where the rest of it went and why
canvasEl.addEventListener('pointerdown', onDown);
}
}, 0);
} else {
controls.className = 'effect-controls';
}
// Generate Mix slider - the anchors have no wet and no dry
if (!isAnchorEffect(idx)) {
const mixPotDef = { name: 'Mix', curve: 'LINEAR', min: 0, max: 100, unit: '%' };
const mixDiv = document.createElement('div');
mixDiv.className = 'pot-control mix-pot-control';
const mixLabel = document.createElement('div');
mixLabel.className = 'pot-label';
mixLabel.textContent = mixPotDef.name;
const defaultMixVal = Math.round((effect.defMix !== undefined ? effect.defMix : 1.0) * 120);
const mixValDisplay = document.createElement('div');
mixValDisplay.className = 'pot-value';
mixValDisplay.textContent = formatPotValue(mixPotDef, defaultMixVal);
const mixInput = document.createElement('input');
mixInput.type = 'range';
mixInput.min = 0;
mixInput.max = 120;
mixInput.value = defaultMixVal;
mixInput.potDef = mixPotDef;
enableWheelAdjust(mixInput, mixDiv);
ccToElementMap.set(`eff-${idx}-mix`, mixInput);
mixInput.addEventListener('input', (e) => {
const midiVal = parseInt(e.target.value);
mixValDisplay.textContent = formatPotValue(mixPotDef, midiVal);
sendSysex([SYSEX_CMD.PARAM_UPDATE, effect.id, 0, midiVal]);
});
mixDiv.appendChild(mixLabel);
mixDiv.appendChild(mixValDisplay);
mixDiv.appendChild(mixInput);
openOnTap(mixDiv, mixInput, () =>
setActivePot(`eff-${idx}-mix`, mixPotDef,
parseInt(mixInput.value), effect.name));
// The EQ puts it in a row with its own switches
(eqFooter || controls).appendChild(mixDiv);
}
effect.pots.forEach((pot, pIdx) => {
const potIdKey = `eff-${idx}-pot-${pIdx}`;
// Whether the graph draws a node for this one, which
// decides where it goes rather than how it looks
const banded = isEq && pIdx < bands.length * 2;
const potDiv = document.createElement('div');
potDiv.className = 'pot-control';
const label = document.createElement('div');
label.className = 'pot-label';
label.textContent = pot.name;
//
// What the control is for, if the effect said. On the whole
// control rather than the label, because the slider is the
// part you are already pointing at when you wonder.
//
// The pedal supplies this along with everything else it
// reports about itself, so a firmware that gains a pot gains
// its explanation too, without the app being redeployed.
//
if (pot.info) {
potDiv.title = `${pot.name}${pot.info}`;
potDiv.classList.add('has-info');
}
const initialVal = getInitialPotValue(pot);
if (pot.curve === 'ENUM' && pot.enum) {
const select = document.createElement('select');
select.className = 'enum-select';
pot.enum.forEach((optStr, idx) => {
const opt = document.createElement('option');
opt.value = idx;
opt.textContent = optStr;
select.appendChild(opt);
});
select.value = initialVal;
ccToElementMap.set(potIdKey, select);
select.addEventListener('change', (e) => {
const midiVal = parseInt(e.target.value);
sendSysex([SYSEX_CMD.PARAM_UPDATE, effect.id, pIdx+1, midiVal]);
});
potDiv.appendChild(label);
potDiv.appendChild(select);
} else {
const valDisplay = document.createElement('div');
valDisplay.className = 'pot-value';
valDisplay.textContent = formatPotValue(pot, initialVal);
const input = document.createElement('input');
input.type = 'range';
input.min = 0;
input.max = 120;
input.value = initialVal;
input.potDef = pot; // Attach pot definition for formatting
// Every pot of a graphed effect feeds the curve, node
// or no node - see bandQ() - so every one redraws it
if (isEq) {
input.redrawCurve = effect.redrawCurve;
eqPotsInputs.push(input);
}
enableWheelAdjust(input, potDiv);
ccToElementMap.set(potIdKey, input);
input.addEventListener('input', (e) => {
const midiVal = parseInt(e.target.value);
valDisplay.textContent = formatPotValue(pot, midiVal);
sendSysex([SYSEX_CMD.PARAM_UPDATE, effect.id, pIdx+1, midiVal]);
if (input.redrawCurve) input.redrawCurve();
});
potDiv.appendChild(label);
potDiv.appendChild(valDisplay);
potDiv.appendChild(input);
//
// Tapping the pot opens the big slider panel - except for
// a mouse grab of the inline slider itself, which is
// someone dragging it, and having the panel and its
// backdrop appear on top mid-drag is no help to anybody.
//
// A tap with a finger opens it wherever it lands, the
// inline slider included: that slider is about 100px wide
// for 121 values, which is not something a thumb can
// aim at, and the panel is what it has instead.
//
openOnTap(potDiv, input, () =>
setActivePot(potIdKey, pot, parseInt(input.value), effect.name));
}
if (!isEq) {
controls.appendChild(potDiv);
} else if (pIdx < bands.length * 2) {
//
// Part of the picture. The slider still exists and is
// still what carries the value - the graph moves it -
// but it is hidden, because the node is the control.
//
slidersContainer.appendChild(potDiv);
} else {
//
// A pot the graph has no node for, so it needs somewhere
// real to live: the row under the curve, beside Mix.
// Hiding it with the others would have made it a control
// that exists and cannot be reached.
//
eqFooter.appendChild(potDiv);
}
});
if (isEq) {
//
// Switches for how the graph behaves. Not pots, and not
// sent anywhere: the pedal has no opinion about any of this
// and no way to store it. They sit here rather than in the
// app's menu because they are about this one card, and
// because a control belongs next to the thing it governs.
//
const options = document.createElement('div');
options.className = 'eq-options';
const orderLabel = document.createElement('label');
orderLabel.className = 'eq-option';
orderLabel.title = 'Bands stop at their neighbours instead of ' +
'passing them. Off, the shelves and peaks can ' +
'be in any order, which the pedal is happy with.';
const orderBox = document.createElement('input');
orderBox.type = 'checkbox';
orderBox.checked = eqKeepOrder;
orderBox.addEventListener('change', () => {
eqKeepOrder = orderBox.checked;
setUiPref('eq.keepOrder', eqKeepOrder);
});
orderLabel.appendChild(orderBox);
orderLabel.appendChild(document.createTextNode('Keep bands in order'));
options.appendChild(orderLabel);
const phaseLabel = document.createElement('label');
phaseLabel.className = 'eq-option';
phaseLabel.title = 'Draw the phase response behind the curve, ' +
'wrapped to +-180 degrees across the same height.';
const phaseBox = document.createElement('input');
phaseBox.type = 'checkbox';
phaseBox.checked = eqShowPhase;
phaseBox.addEventListener('change', () => {
eqShowPhase = phaseBox.checked;
setUiPref('eq.showPhase', eqShowPhase);
effect.redrawCurve();
});
phaseLabel.appendChild(phaseBox);
phaseLabel.appendChild(document.createTextNode('Phase'));
options.appendChild(phaseLabel);
eqFooter.appendChild(options);
setTimeout(() => effect.redrawCurve(), 0);
}
card.appendChild(controls);
//
// The meters live on the Signal Chain card because that is where
// the question gets asked: Level wants to sit above the noise
// floor, and until now the only way to find the floor was to move
// Level until something happened.
//
if (effect.base === 'signal_chain') {
const meters = document.createElement('div');
meters.className = 'meters';
meters.id = 'signal-chain-meters';
meters.textContent = 'no readings yet';
card.appendChild(meters);
}
effectCards.set(effect.id, card);
effectsContainer.appendChild(card);
});
//
// Nothing is in the chain until the pedal says so, and the reply that
// says so is still in flight. Draw that rather than every card at
// once: an effect the pedal is not running has no business looking
// like one that is, even for a moment.
//
applyRouting([]);
}
appTitleEl.addEventListener('click', () => {
if (appTitleEl.textContent.includes('Tap to Connect') || appTitleEl.textContent.includes('Error')) {
appTitleEl.textContent = "Connecting...";
initMidi();
}
});
// Event Listeners
const inSelect = document.getElementById('midi-input-select');
if (inSelect) {
inSelect.addEventListener('change', (e) => {
selectedInputId = e.target.value;
updateMidiState();
});
}
const outSelect = document.getElementById('midi-output-select');
if (outSelect) {
outSelect.addEventListener('change', (e) => {
selectedOutputId = e.target.value;
updateMidiState();
});
}
globalEnableEl.addEventListener('change', (e) => {
sendMidiCc(GLOBAL_ENABLE_CC, e.target.checked ? 127 : 0);
});
const tunerBtn = document.getElementById('tuner-btn');
if (tunerBtn) {
tunerBtn.addEventListener('click', () => {
isTunerMode = !isTunerMode;
sendMidiCc(GLOBAL_ENABLE_CC, isTunerMode ? 68 : 69);
updateTunerModeUI();
});
}
const closeTunerBtn = document.getElementById('close-tuner-btn');
if (closeTunerBtn) {
closeTunerBtn.addEventListener('click', () => {
isTunerMode = false;
sendMidiCc(GLOBAL_ENABLE_CC, 69);
updateTunerModeUI();
});
}
const tunerSynthToggle = document.getElementById('tuner-synth-toggle');
if (tunerSynthToggle) {
tunerSynthToggle.addEventListener('change', (e) => {
playSynth = e.target.checked;
if (!playSynth) stopAllNotes();
if (playSynth) getAudioContext(); // Initialize context if needed
});
}
function closeMenu() {
const menu = document.getElementById('global-menu');
const burger = document.getElementById('burger-btn');
if (menu) menu.classList.add('hidden');
if (burger) burger.setAttribute('aria-expanded', 'false');
}
function closeAllPanels() {
if (document.getElementById('panel-backdrop')) document.getElementById('panel-backdrop').classList.add('hidden');
if (document.getElementById('settings-panel')) document.getElementById('settings-panel').classList.add('hidden');
if (document.getElementById('bindings-panel')) document.getElementById('bindings-panel').classList.add('hidden');
if (document.getElementById('active-pot-panel')) document.getElementById('active-pot-panel').classList.add('hidden');
closeMenu();
activePotCc = null;
activePotDef = null;
}
const backdrop = document.getElementById('panel-backdrop');
if (backdrop) {
backdrop.addEventListener('click', closeAllPanels);
}
// A button whose label depends on something that can change while
// the message is up rebuilds it rather than restoring whatever it
// happened to say a second and a half ago.
function restoreButton(btn, originalText) {
if (btn.relabel)
btn.relabel();
else
btn.innerHTML = originalText;
btn.classList.remove('success', 'error');
}
//
// Every one of these is a menu item, so success means the menu has
// done its job and should go away. It used to stay open, which made
// sense when a round of testing meant several of these in a row and
// reaching for the burger between each was the annoying part. There
// is nothing like that left, so now it just sits there afterwards
// looking like it is waiting for something.
//
// Closing on the same timer that restores the label rather than
// immediately: the label *is* the confirmation, and 'Saved to 3' is
// worth reading before it goes. Failures keep the menu open for the
// same reason - showButtonError() does not close it, because the
// error is the thing you need to see.
//
// A second, and not the second and a half it was. Long enough to
// read four words, and short enough that it never reads as the menu
// having decided to stay open - which is what it looked like, having
// spent so long doing exactly that.
//
const BUTTON_FEEDBACK_MS = 1000;
function showButtonSuccess(btn, successText) {
const originalText = btn.innerHTML;
btn.innerHTML = `${successText}`;
btn.classList.add('success');
setTimeout(() => {
restoreButton(btn, originalText);
closeMenu();
}, BUTTON_FEEDBACK_MS);
}
function showButtonError(btn, errorText) {
const originalText = btn.innerHTML;
btn.innerHTML = `⚠️ ${errorText}`;
btn.classList.add('error');
setTimeout(() => restoreButton(btn, originalText), BUTTON_FEEDBACK_MS);
}
// The burger menu
const burgerBtn = document.getElementById('burger-btn');
const globalMenu = document.getElementById('global-menu');
if (burgerBtn && globalMenu) {
burgerBtn.addEventListener('click', (e) => {
// Or the document listener below sees this same click and
// closes what we just opened.
e.stopPropagation();
const wasOpen = !globalMenu.classList.contains('hidden');
closeAllPanels();
if (!wasOpen) {
globalMenu.classList.remove('hidden');
burgerBtn.setAttribute('aria-expanded', 'true');
}
});
// A menu goes away when you press somewhere else, which the
// backdrop used to do for us and a dropdown has no backdrop for.
document.addEventListener('click', (e) => {
if (!globalMenu.classList.contains('hidden') &&
!globalMenu.contains(e.target))
closeMenu();
});
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape')
closeAllPanels();
});
// Settings, which is the one menu item that opens something rather
// than doing something.
const openSettingsBtn = document.getElementById('open-settings-btn');
const settingsPanel = document.getElementById('settings-panel');
const closeSettingsBtn = document.getElementById('close-settings');
if (openSettingsBtn && settingsPanel) {
openSettingsBtn.addEventListener('click', () => {
closeAllPanels();
settingsPanel.classList.remove('hidden');
if (backdrop) backdrop.classList.remove('hidden');
});
}
if (closeSettingsBtn) {
closeSettingsBtn.addEventListener('click', closeAllPanels);
}
// Active Pot initialization
const openBindingsBtn = document.getElementById('open-bindings-btn');
if (openBindingsBtn) {
openBindingsBtn.addEventListener('click', () => {
closeAllPanels();
renderBindings();
document.getElementById('bindings-panel').classList.remove('hidden');
if (backdrop) backdrop.classList.remove('hidden');
});
}
const closeBindingsBtn = document.getElementById('close-bindings');
if (closeBindingsBtn)
closeBindingsBtn.addEventListener('click', closeAllPanels);
//
// Point the knob at whatever pot the panel is showing. The hint
// beside it says what is being taken away, because with one knob
// every assignment takes it off something else.
//
const assignKnobBtn = document.getElementById('assign-knob-btn');
if (assignKnobBtn) {
assignKnobBtn.addEventListener('click', () => {
const t = activePotTarget();
if (!t)
return;
//
// Move the knob's first rule rather than adding another,
// since "assign" means point it here and a second rule
// would mean drive both.
//
const i = pedalRules.findIndex(r => r.control === 0 &&
r.action === ACT.POT);
const r = { control: 0, action: ACT.POT,
effect: t.effId, pot: t.pot, val: [0, 0] };
if (i < 0)
sendRules(pedalRules.concat([r]));
else
putRule(i, r);
});
}
const closeActivePotBtn = document.getElementById('close-active-pot');
if (closeActivePotBtn) {
closeActivePotBtn.addEventListener('click', () => {
closeAllPanels();
});
}
const activePotSlider = document.getElementById('active-pot-slider');
if (activePotSlider) {
// the whole panel: its name and readout are part of the pot too
enableWheelAdjust(activePotSlider,
document.getElementById('active-pot-panel') || activePotSlider);
activePotSlider.addEventListener('input', (e) => {
if (activePotCc === null || !activePotDef) return;
const val = parseInt(e.target.value);
const valDisplay = document.getElementById('active-pot-value');
if (valDisplay)
valDisplay.textContent = formatPotValue(activePotDef, val);
// Update original element
const origInput = ccToElementMap.get(activePotCc);
if (origInput) {
origInput.value = val;
const valDisplay = origInput.parentElement.querySelector('.pot-value');
if (valDisplay) valDisplay.textContent = formatPotValue(activePotDef, val);
if (origInput.redrawCurve) origInput.redrawCurve();
}
// Parse activePotCc to get effectId and potIdx
// activePotCc is like "eff-2-pot-0" or "eff-2-mix"
const parts = activePotCc.split('-');
if (parts.length >= 4 && parts[2] === 'pot') {
const idx = parseInt(parts[1]);
const pIdx = parseInt(parts[3]);
const effId = PEDAL_EFFECTS[idx].id;
sendSysex([SYSEX_CMD.PARAM_UPDATE, effId, pIdx + 1, val]);
} else if (parts.length === 3 && parts[2] === 'mix') {
const idx = parseInt(parts[1]);
const effId = PEDAL_EFFECTS[idx].id;
sendSysex([SYSEX_CMD.PARAM_UPDATE, effId, 0, val]);
}
});
}
function setActivePot(cc, potDef, currentVal, effectName) {
const panel = document.getElementById('active-pot-panel');
const name = document.getElementById('active-pot-title');
const valDisplay = document.getElementById('active-pot-value');
// No panel, nothing to make active - and in particular don't set
// activePotCc, which everything else takes as "the panel is up".
if (!panel)
return;
closeAllPanels();
activePotCc = cc;
activePotDef = potDef;
if (name) name.textContent = `${effectName} - ${potDef.name}`;
if (valDisplay) valDisplay.textContent = formatPotValue(potDef, currentVal);
if (activePotSlider) activePotSlider.value = currentVal;
renderKnobHint();
panel.classList.remove('hidden');
if (backdrop) backdrop.classList.remove('hidden');
}
const globalUnrouteBtn = document.getElementById('global-unroute-btn');
if (globalUnrouteBtn) {
globalUnrouteBtn.addEventListener('click', () => {
if (!midiOutput) {
showButtonError(globalUnrouteBtn, 'Not Connected');
return;
}
// A routing order with nothing in it unroutes everything. The
// two anchors are never part of the routing order to begin
// with - one always runs first, the other is not an effect.
setRouting([]);
showButtonSuccess(globalUnrouteBtn, 'All Unrouted');
});
}
// The options are filled in from the identity reply, since only the
// pedal knows how many scenes it has - see populateScenePicker().
const sceneSelect = document.getElementById('global-scene-select');
//
// The picker is in the header and the two things that act on it are
// in the menu, so the menu says which scene it means. Otherwise you
// are trusting your memory of a control that isn't on screen while
// you press the one that overwrites it.
//
const loadSceneBtn = document.getElementById('global-load-scene-btn');
if (loadSceneBtn) {
loadSceneBtn.addEventListener('click', () => {
if (!midiOutput) {
showButtonError(loadSceneBtn, 'Not Connected');
return;
}
const sceneId = parseInt(sceneSelect.value);
sendMidiPc(sceneId);
setTimeout(() => {
sendSysex([SYSEX_CMD.REQ_STATE]);
sendSysex([SYSEX_CMD.DIAGNOSTIC]); // Check status after load
}, 100);
showButtonSuccess(loadSceneBtn, `Loaded ${sceneId}`);
});
}
const saveSceneBtn = document.getElementById('global-save-scene-btn');
if (saveSceneBtn) {
saveSceneBtn.addEventListener('click', () => {
if (!midiOutput) {
showButtonError(saveSceneBtn, 'Not Connected');
return;
}
const sceneId = parseInt(sceneSelect.value);
sendSysex([SYSEX_CMD.SAVE_SCENE, sceneId]);
sendSysex([SYSEX_CMD.DIAGNOSTIC]); // Check status after save
showButtonSuccess(saveSceneBtn, `Saved to ${sceneId}`);
});
}
if (sceneSelect) {
sceneSelect.addEventListener('change', updateSceneLabels);
if (loadSceneBtn) loadSceneBtn.relabel = updateSceneLabels;
if (saveSceneBtn) saveSceneBtn.relabel = updateSceneLabels;
updateSceneLabels();
}
const globalProgramBtn = document.getElementById('global-program-btn');
if (globalProgramBtn) {
globalProgramBtn.addEventListener('click', () => {
if (!midiOutput) {
showButtonError(globalProgramBtn, 'Not Connected');
return;
}
// The confirmation is there because rebooting into the
// bootloader in the middle of playing would be a nasty
// surprise. Developing against the thing, you do it over
// and over on purpose, and the prompt is pure friction.
if (!IS_LOCAL_DEV && !confirm("Reboot pedal into programming mode?"))
return;
sendMidiCc(GLOBAL_ENABLE_CC, 126);
closeAllPanels();
});
}
// Boot
renderUI();
initMidi();