Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions web/public/pattern-sandbox.html

Large diffs are not rendered by default.

22 changes: 11 additions & 11 deletions web/src/lib/guide/deviceSim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@
// device prints on its KNOB MAP screen. Where each one sits on the front is
// the 3D stage's business.

import { PATTERN_DETENTS_PER_TURN, PatternRuntime, type ColorRamp, type PatternInput } from "@/lib/pattern/harness";
import { PatternRuntime, type ColorRamp, type PatternInput } from "@/lib/pattern/harness";
import { parseRampAnnotation } from "@/lib/pattern/ramp";
import { hexToRgb } from "@/lib/pattern/color";
import { knobSetupFromCode } from "@/lib/community/knobs";
import { LOGICAL_KNOB_DEFAULTS, knobUnitsPerTurn } from "@/lib/pattern/controls";
import { livePresets } from "@/lib/presets";
import { BASICS_PACK } from "@/lib/pattern/packs";
import { BUS_MAX, BUS_UNITS_PER_CLICK, DETENTS_PER_TURN, EDGES_PER_DETENT, KNOB_COUNT } from "@/lib/shared/constants";
import { BASICS_NAMES } from "./basicsNames";
import {
brightnessPercent,
Expand Down Expand Up @@ -145,7 +146,6 @@ export type SimConsoleState = {
// write. A channel is held once written; a change of the held value reaches
// a pattern as clicks, ten bus units each, the remainder carried; a hand on
// that encoder takes it back after a grace.
const BUS_UNITS_PER_CLICK = 10;
const BUS_RELEASE_GRACE_MS = 250;

// The lanes (docs/audio-ws-spec.md): what sound writes. A lane is an absolute
Expand Down Expand Up @@ -197,7 +197,7 @@ export class DeviceSim {
private now = 0;
private time = 0;

private knobs: Knob[] = Array.from({ length: 4 }, () => ({
private knobs: Knob[] = Array.from({ length: KNOB_COUNT }, () => ({
down: false,
downAt: 0,
longFired: false,
Expand Down Expand Up @@ -330,7 +330,7 @@ export class DeviceSim {
* the AUD row switched off stops all four.
*/
setLane(source: SimLaneSource, knob: number, value: number) {
if (source !== this.laneSource || !Number.isInteger(knob) || knob < 0 || knob > 3 || !Number.isFinite(value)) return;
if (source !== this.laneSource || !Number.isInteger(knob) || knob < 0 || knob >= KNOB_COUNT || !Number.isFinite(value)) return;
this.laneValue[knob] = clamp(value, 0, 1);
this.laneAt[knob] = this.now;
}
Expand Down Expand Up @@ -499,8 +499,8 @@ export class DeviceSim {
* (core_bus.h applyRemoteParam). The 3D knob does not turn — nothing did.
*/
applyRemoteParam(knob: number, value: number) {
if (knob < 0 || knob > 3 || !Number.isFinite(value)) return;
const v = clamp(Math.round(value), 0, 1000);
if (knob < 0 || knob >= KNOB_COUNT || !Number.isFinite(value)) return;
const v = clamp(Math.round(value), 0, BUS_MAX);
if (this.busHeld[knob]) {
const acc = this.busResidual[knob] + (v - this.busValue[knob]);
const d = Math.trunc(acc / BUS_UNITS_PER_CLICK);
Expand All @@ -520,9 +520,9 @@ export class DeviceSim {

/** The /knobs page: which way each encoder counts and how many of its 4 edges make a click. */
setKnobSettings(invert: boolean[], edgesPerClick: number[]) {
for (let i = 0; i < 4; i++) {
const sub = [1, 2, 4].includes(edgesPerClick[i]) ? edgesPerClick[i] : 4;
this.clickScale[i] = (invert[i] ? -1 : 1) * (4 / sub);
for (let i = 0; i < KNOB_COUNT; i++) {
const sub = [1, 2, 4].includes(edgesPerClick[i]) ? edgesPerClick[i] : EDGES_PER_DETENT;
this.clickScale[i] = (invert[i] ? -1 : 1) * (EDGES_PER_DETENT / sub);
}
}

Expand Down Expand Up @@ -700,7 +700,7 @@ export class DeviceSim {
const d = detents[i];
if (!d) continue;
const [min, max] = this.ranges[i] ?? [0, 1];
const next = values[i] + d * (knobUnitsPerTurn([min, max]) / PATTERN_DETENTS_PER_TURN);
const next = values[i] + d * (knobUnitsPerTurn([min, max]) / DETENTS_PER_TURN);
values[i] = this.wraps[i] ? wrap(next, min, max) : clamp(next, min, max);
}
}
Expand All @@ -710,7 +710,7 @@ export class DeviceSim {
// SELECT clears the name card (patternflow.ino:1731).
if (mode === "select") this.noticeUntil = -1;
// SELECT drops every held bus channel, so browsing never fights one (patternflow.ino).
if (mode === "select") for (let i = 0; i < 4; i++) if (this.busHeld[i]) this.releaseBus(i);
if (mode === "select") for (let i = 0; i < KNOB_COUNT; i++) if (this.busHeld[i]) this.releaseBus(i);
this.mode = mode;
this.idleAt = this.now;
this.onModeChange?.(mode);
Expand Down
111 changes: 61 additions & 50 deletions web/src/lib/guide/panelScreens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
// firmware/patternflow/src/hub75/ESP32-HUB75-MatrixPanel-I2S-DMA.h
// drawPixel/fillRect overrides, the
// rotation transform, RGB565 -> 888
// Line numbers cited here are from those files at v3.10.4. When a screen
// changes there, change it here - the test pins a few pixels so drift shows.
// Firmware constants are cited by name. Other line numbers cited here are
// from those files at v3.10.4. When a screen changes there, change it here -
// the test pins a few pixels so drift shows.
//
// Things the port reproduces on purpose, because the panel does them:
// - Adafruit GFX's text wrap is on (the default; nothing in the firmware turns
Expand All @@ -36,11 +37,18 @@
// License: MIT. The two font tables this draws with are BSD - see glcdFont.ts
// and tomThumbFont.ts for their notices.

import {
BUTTON_DEBOUNCE_MS,
EDGES_PER_DETENT,
LONG_PRESS_MS,
PANEL_GEOMETRIES,
PANEL_GEOM_DEFAULT,
} from "@/lib/shared/constants";
import { GLCD_FONT } from "./glcdFont";
import { TOM_THUMB, type GfxFont } from "./tomThumbFont";

export const PANEL_W = 128;
export const PANEL_H = 64;
export const PANEL_W: number = PANEL_GEOMETRIES[PANEL_GEOM_DEFAULT].resW;
export const PANEL_H: number = PANEL_GEOMETRIES[PANEL_GEOM_DEFAULT].resH;
const FRAME_BYTES = PANEL_W * PANEL_H * 4;

export type Rgb = readonly [number, number, number];
Expand Down Expand Up @@ -71,15 +79,15 @@ export type PanelOverlay =
wifi: WifiStatusText | (string & {});
/** ipString(): the dotted IP, or "-" when not connected (src/core_wifi.h:629-632). */
ip: string;
/** Feature toggle rows; a core build has none. At most two are drawn (patternflow.ino:759). */
/** Feature toggle rows; a core build has none. At most two are drawn (MAX_FEATURE_ROWS, patternflow.ino). */
rows?: { name: string; on: boolean }[];
/** The hotspot's name, e.g. "patternflow-a1b2", when the panel's own AP is up. Shown only while not connected. */
hotspotName?: string;
/** PatternflowWifi::isConnected(). Defaults to wifi === "CONNECTED". */
connected?: boolean;
/** "K1 = SLEEP" line; true unless a build compiles sleep out (config.h:247-248). */
/** "K1 = SLEEP" line; true unless a build compiles sleep out (PF_SLEEP_ENABLED, config.h). */
sleepHint?: boolean;
/** "K4=UPDATE" line; true unless a build compiles the web update out (net_config.h:172-173). */
/** "K4=UPDATE" line; true unless a build compiles the web update out (PF_WEBUPDATE_ENABLED, net_config.h). */
updateHint?: boolean;
}
| {
Expand Down Expand Up @@ -123,7 +131,7 @@ export type PanelOverlay =

// ── Firmware constants ──────────────────────────────────────────────────────

const DEFAULT_BRIGHTNESS = 204; // firmware/patternflow/config.h:241 ("80%")
const DEFAULT_BRIGHTNESS = 204; // DEFAULT_BRIGHTNESS, firmware/patternflow/config.h ("80%")

/** The percentage the panel prints for a brightness byte (patternflow.ino:693). */
export function brightnessPercent(level: number): number {
Expand Down Expand Up @@ -155,50 +163,53 @@ export interface FirmwareConstants {
}

export const FIRMWARE: Readonly<FirmwareConstants> = Object.freeze({
// MODE_HOLD_MS, patternflow.ino:269. Fires WHILE still held, on the first
// frame the hold exceeds it (strict >, src/core_encoders.h:172); the release
// that follows is not a click (core_encoders.h:151-154).
longPressMs: 1000,
// Button edges closer than this are ignored (src/core_encoders.h:143).
buttonDebounceMs: 50,
// Quadrature edges per detent (knobSubSteps default, src/core_encoders.h:76);
// one detent = one knob delta (patternflow.ino:1168-1182, no acceleration).
edgesPerDetent: 4,
// BRIGHTNESS_IDLE_MS, patternflow.ino:270; checked with > at :1566. The timer
// restarts on entry (:1539) and only when a turn actually changes the level
// (:1555-1559) - turning against the 5/255 clamp does not keep it open.
// MODE_HOLD_MS in patternflow.ino, LONG_PRESS_MS in shared/constants.json.
// Fires WHILE still held, on the first frame the hold exceeds it (strict >,
// Button::longPressed in src/core_encoders.h); the release that follows is
// not a click (Button::pressed).
longPressMs: LONG_PRESS_MS,
// Button edges closer than this are ignored (Button::pressed in
// src/core_encoders.h, BUTTON_DEBOUNCE_MS in shared/constants.json).
buttonDebounceMs: BUTTON_DEBOUNCE_MS,
// Quadrature edges per detent (the knobSubSteps default in
// src/core_encoders.h, EDGES_PER_DETENT in shared/constants.json); one
// detent = one knob delta (readInputFrame in patternflow.ino, no acceleration).
edgesPerDetent: EDGES_PER_DETENT,
// BRIGHTNESS_IDLE_MS in patternflow.ino, checked with > in loop(). The timer
// restarts on entry and only when a turn actually changes the level -
// turning against the 5/255 clamp does not keep it open.
brightnessIdleExitMs: 5000,
// BRIGHTNESS_SAVE_DELAY_MS, patternflow.ino:273 (NVS write after the mode closes).
// BRIGHTNESS_SAVE_DELAY_MS in patternflow.ino (NVS write after the mode closes).
brightnessSaveDelayMs: 3000,
// OSC_INFO_IDLE_MS (the NETWORK screen's timer despite the name), patternflow.ino:274, :1658.
// OSC_INFO_IDLE_MS in patternflow.ino (the NETWORK screen's timer despite the name).
networkIdleExitMs: 8000,
// KNOB_MAP_IDLE_MS, patternflow.ino:276, :1687.
// KNOB_MAP_IDLE_MS in patternflow.ino.
knobmapIdleExitMs: 8000,
// KNOB_MAP_HILITE_MS, patternflow.ino:277; a knob stays lit this long after its last detent (:1005-1006).
// KNOB_MAP_HILITE_MS in patternflow.ino: a knob stays lit this long after its last detent (drawKnobMap).
knobmapHighlightMs: 600,
// UPDATE_IDLE_MS, patternflow.ino:278, :1711 (not while flashing).
// UPDATE_IDLE_MS in patternflow.ino (not while flashing).
updateIdleExitMs: 600000,
// NET_INFO_REDRAW_MS, patternflow.ino:275: info screens repaint at most this often (or on a change).
// NET_INFO_REDRAW_MS in patternflow.ino: info screens repaint at most this often (or on a change).
infoRedrawMs: 250,
// SELECT_DETENTS_PER_STEP, patternflow.ino:240; see stepSelect().
// SELECT_DETENTS_PER_STEP in patternflow.ino; see stepSelect().
selectDetentsPerStep: 3,
// SELECT_SETTLE_MS, patternflow.ino:234: the highlighted pattern loads once K4 has rested this long (:1943).
// SELECT_SETTLE_MS in patternflow.ino: the highlighted pattern loads once K4 has rested this long.
selectSettleMs: 350,
// PATTERN_SAVE_DELAY_MS, patternflow.ino:207 (only once SELECT is left, :313).
// PATTERN_SAVE_DELAY_MS in patternflow.ino (only once SELECT is left, savePatternIfSettled).
patternSaveDelayMs: 3000,
// CONTENT_NOTICE_SECONDS, patternflow.ino:279 - the name card after a remote pick; SELECT never shows it.
// CONTENT_NOTICE_SECONDS in patternflow.ino - the name card after a remote pick; SELECT never shows it.
contentNoticeMs: 1000,
// constrain(currentBrightness + d * 5, 5, 255), patternflow.ino:1554.
// constrain(currentBrightness + d * 5, 5, 255) in loop(), patternflow.ino.
brightnessMin: 5,
brightnessMax: 255,
brightnessStepPerDetent: 5,
// DEFAULT_BRIGHTNESS, config.h:241 - a byte, not a percent.
// DEFAULT_BRIGHTNESS in config.h - a byte, not a percent.
defaultBrightness: DEFAULT_BRIGHTNESS,
// brightnessPercent(204) = 80.
defaultBrightnessPercent: brightnessPercent(DEFAULT_BRIGHTNESS),
// MAX_FEATURE_ROWS, patternflow.ino:759.
// MAX_FEATURE_ROWS in drawNetworkInfo, patternflow.ino.
networkMaxFeatureRows: 2,
// WAKE_GUARD_MS, src/core_sleep.h:64: input ignored this long after falling asleep.
// WAKE_GUARD_MS in src/core_sleep.h: input ignored this long after falling asleep.
wakeGuardMs: 800,
});

Expand All @@ -224,7 +235,7 @@ export function wrapIndex(index: number, count: number): number {
return ((index % count) + count) % count;
}

// Fixed-size name buffers: MODULE_NAME_BYTES, firmware/patternflow/pattern_registry.h:139.
// Fixed-size name buffers: MODULE_NAME_BYTES, firmware/patternflow/pattern_registry.h.
const MODULE_NAME_BYTES = 40;
const LATIN1_FOLD =
"AAAAAAACEEEEIIII" + // C0-CF
Expand All @@ -235,8 +246,8 @@ const LATIN1_FOLD =
/**
* A pattern name as the panel prints it: asciiFold (patternflow.ino:577-601)
* over the UTF-8 bytes. Latin-1 letters lose their accent, anything else
* becomes one '?'. Stored names live in 40-byte buffers (pattern_registry.h:139,
* snprintf at :217), so at most 39 bytes of the name survive.
* becomes one '?'. Stored names live in 40-byte buffers (MODULE_NAME_BYTES in
* pattern_registry.h, filled with snprintf), so at most 39 bytes of the name survive.
*/
export function asciiFold(name: string): string {
const bytes = new TextEncoder().encode(name).subarray(0, MODULE_NAME_BYTES - 1);
Expand Down Expand Up @@ -282,17 +293,17 @@ const rgb565 = (r: number, g: number, b: number): Rgb => color565to888(color565(
/** The firmware's panel palette, as it lands in the frame buffer. */
export const PANEL_COLORS = Object.freeze({
black: rgb565(0, 0, 0),
led: rgb565(232, 85, 46), // pfLedC, patternflow.ino:635
white: rgb565(255, 255, 255), // pfWhiteC, :636
gray: rgb565(140, 140, 140), // pfGrayC, :637
dim: rgb565(90, 90, 90), // pfDimC, :638
rule: rgb565(60, 60, 60), // pfRuleC, :639
green: rgb565(80, 220, 130), // pfGreenC, :640
red: rgb565(255, 80, 80), // pfRedC, :641
blue: rgb565(120, 180, 255), // pfBlueC, :642
selectPage: rgb565(190, 190, 190), // "i / N", :1054
selectHint: rgb565(200, 200, 200), // "HOLD TO SELECT", :1084
knobActiveFill: rgb565(74, 27, 15), // KNOB MAP active disc, :1009
led: rgb565(232, 85, 46), // pfLedC in patternflow.ino
white: rgb565(255, 255, 255), // pfWhiteC
gray: rgb565(140, 140, 140), // pfGrayC
dim: rgb565(90, 90, 90), // pfDimC
rule: rgb565(60, 60, 60), // pfRuleC
green: rgb565(80, 220, 130), // pfGreenC
red: rgb565(255, 80, 80), // pfRedC
blue: rgb565(120, 180, 255), // pfBlueC
selectPage: rgb565(190, 190, 190), // "i / N", drawSelectingMode
selectHint: rgb565(200, 200, 200), // "HOLD TO SELECT", drawSelectingMode
knobActiveFill: rgb565(74, 27, 15), // KNOB MAP active disc, drawKnobMap
});

// ── A small Adafruit GFX over an RGBA frame ─────────────────────────────────
Expand Down Expand Up @@ -729,8 +740,8 @@ function drawUiLine(g: PanelGfx, font: GfxFont | null, text: string, yTop: numbe
}

// PatternflowUiText::drawWrappedName, src/core_ui_text.h:447-449 -> drawWrapped
// (:388-445) with PF_UI_FONT_SELECT = the built-in 5x7 (core_ui_fonts.h:50-52)
// and pitch 9 (:64-66).
// (:388-445) with PF_UI_FONT_SELECT = the built-in 5x7 and PF_UI_PITCH_SELECT
// = 9 (both in core_ui_fonts.h).
//
// Only drawWrapped's first branch is ported, because it is the only one that
// runs: it measures the whole name with getTextBounds, and with GFX wrap on,
Expand Down
21 changes: 11 additions & 10 deletions web/src/lib/pattern/controls.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { PATTERN_DETENTS_PER_TURN } from "./harness";
import { BUS_MAX, DETENTS_PER_TURN, TURNS_PER_FULL_RANGE } from "@/lib/shared/constants";

// Detents in one full turn of the physical encoder. One fact, one constant:
// harness.ts owns it (see the note there — the reference Bourns
// PEC11R-...-S0024 is 24, not the 20 assumed for a long time) and this is the
// name the knob math has always used for it.
export const ENCODER_CLICKS_PER_TURN = PATTERN_DETENTS_PER_TURN;
// shared/constants.json owns it (see the note in harness.ts — the reference
// Bourns PEC11R-...-S0024 is 24, not the 20 assumed for a long time) and this
// is the name the knob math has always used for it.
export const ENCODER_CLICKS_PER_TURN = DETENTS_PER_TURN;

// The device's absolute bus speaks integers 0..1000 (PF_BUS_MAX in
// firmware/patternflow/src/core_bus.h). Everything here that writes a wire
// value — the Director's bake, the .pfs codec, the MIDI export — uses this
// rather than the literal, so the two sides can only disagree in one place.
export const BUS_WIRE_MAX = 1000;
// firmware/patternflow/src/core_bus.h, BUS_MAX in shared/constants.json).
// Everything here that writes a wire value — the Director's bake, the .pfs
// codec, the MIDI export — uses this rather than the literal, so the two sides
// can only disagree in one place.
export const BUS_WIRE_MAX = BUS_MAX;

// How many full turns cross a parameter's ENTIRE range.
//
Expand All @@ -21,7 +22,7 @@ export const BUS_WIRE_MAX = 1000;
// Deriving travel from the range makes every knob feel the same regardless of
// what it controls, and keeps the web preview and the encoder in step because
// both read the value from here.
export const TURNS_PER_FULL_RANGE = 2;
export { TURNS_PER_FULL_RANGE };

/** Value units covered by one full turn, for a parameter spanning `range`. */
export function knobUnitsPerTurn(range: readonly [number, number]) {
Expand Down
14 changes: 8 additions & 6 deletions web/src/lib/pattern/harness.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
export const PATTERN_MATRIX_WIDTH = 128;
export const PATTERN_MATRIX_HEIGHT = 64;
export const PATTERN_KNOB_COUNT = 4;
import { DETENTS_PER_TURN, KNOB_COUNT, PANEL_GEOMETRIES, PANEL_GEOM_DEFAULT } from "@/lib/shared/constants";

export const PATTERN_MATRIX_WIDTH: number = PANEL_GEOMETRIES[PANEL_GEOM_DEFAULT].resW;
export const PATTERN_MATRIX_HEIGHT: number = PANEL_GEOMETRIES[PANEL_GEOM_DEFAULT].resH;
export const PATTERN_KNOB_COUNT = KNOB_COUNT;
// Detents in one full turn of the physical encoder. The BOM reference part is
// Bourns PEC11R-4220F-S0024 — the S0024 suffix means 24 detents / 24 PPR, which
// distributors confirm. This was 20 for a long time, which silently put the web
// preview 20 % out of step with the hardware it claims to mirror.
// Sourcing a different encoder? Generic EC11s ship as 20, 24 or 30 detents —
// this constant has to match whatever is actually in the build.
// Keep in sync with ENCODER_CLICKS_PER_TURN in patternflowControls.ts.
export const PATTERN_DETENTS_PER_TURN = 24;
// DETENTS_PER_TURN in shared/constants.json has to match whatever is actually
// in the build. controls.ts reads the same value as ENCODER_CLICKS_PER_TURN.
export const PATTERN_DETENTS_PER_TURN = DETENTS_PER_TURN;

export type PatternParams = Record<string, unknown>;

Expand Down
9 changes: 7 additions & 2 deletions web/src/lib/pattern/matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,15 @@
// public/pattern-sandbox.html is built from this module
// (scripts/build-sandbox.ts), so there is no second parser to keep in step.

import { PANEL_GEOMETRIES, PANEL_GEOM_DEFAULT } from "@/lib/shared/constants";

export type MatrixSize = { width: number; height: number };

/** The stock Patternflow panel. Used whenever a pattern carries no @matrix. */
export const DEFAULT_MATRIX: MatrixSize = { width: 128, height: 64 };
export const DEFAULT_MATRIX: MatrixSize = {
width: PANEL_GEOMETRIES[PANEL_GEOM_DEFAULT].resW,
height: PANEL_GEOMETRIES[PANEL_GEOM_DEFAULT].resH,
};

/**
* The stock panel in either orientation — the board is mounted whichever way
Expand Down Expand Up @@ -69,7 +74,7 @@ export function clampMatrixDimension(value: number): number | null {
* Pixels per frame beyond which the JS preview starts to struggle — four times
* the stock panel. Not a limit, just the point worth warning about.
*/
export const MATRIX_HEAVY_PIXELS = 128 * 64 * 4;
export const MATRIX_HEAVY_PIXELS = DEFAULT_MATRIX.width * DEFAULT_MATRIX.height * 4;

export function isValidMatrix(size: Partial<MatrixSize> | null | undefined): boolean {
if (!size) return false;
Expand Down
Loading