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
45 changes: 45 additions & 0 deletions firmware/patternflow/src/core_shared.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// GENERATED by web/scripts/gen-shared.ts from shared/constants.json. Do not edit.
// Change the JSON and run `npm run gen:shared` in web/. check:shared fails
// while this file says anything else.
//
// License: MIT
#pragma once

// The geometries PANEL_GEOMETRY picks from in config.h. Two 64x64 modules in a
// chain make the same 128x64 canvas as one 128x64 module.
#define PF_SHARED_PANEL_GEOM_128x64_SINGLE 1
#define PF_SHARED_PANEL_GEOM_128x64_SINGLE_PHYS_W 128
#define PF_SHARED_PANEL_GEOM_128x64_SINGLE_PHYS_H 64
#define PF_SHARED_PANEL_GEOM_128x64_SINGLE_CHAIN 1
#define PF_SHARED_PANEL_GEOM_128x64_SINGLE_RES_W 128
#define PF_SHARED_PANEL_GEOM_128x64_SINGLE_RES_H 64
#define PF_SHARED_PANEL_GEOM_64x64_SINGLE 2
#define PF_SHARED_PANEL_GEOM_64x64_SINGLE_PHYS_W 64
#define PF_SHARED_PANEL_GEOM_64x64_SINGLE_PHYS_H 64
#define PF_SHARED_PANEL_GEOM_64x64_SINGLE_CHAIN 1
#define PF_SHARED_PANEL_GEOM_64x64_SINGLE_RES_W 64
#define PF_SHARED_PANEL_GEOM_64x64_SINGLE_RES_H 64
#define PF_SHARED_PANEL_GEOM_64x64_CHAIN2 3
#define PF_SHARED_PANEL_GEOM_64x64_CHAIN2_PHYS_W 64
#define PF_SHARED_PANEL_GEOM_64x64_CHAIN2_PHYS_H 64
#define PF_SHARED_PANEL_GEOM_64x64_CHAIN2_CHAIN 2
#define PF_SHARED_PANEL_GEOM_64x64_CHAIN2_RES_W 128
#define PF_SHARED_PANEL_GEOM_64x64_CHAIN2_RES_H 64
#define PF_SHARED_PANEL_GEOM_DEFAULT PF_SHARED_PANEL_GEOM_128x64_SINGLE

// The PEC11R-4220F-S0024 has 24 detents a turn. Two turns cross a parameter's
// whole range, so 48 detents do.
#define PF_SHARED_KNOB_COUNT 4
#define PF_SHARED_DETENTS_PER_TURN 24
#define PF_SHARED_TURNS_PER_FULL_RANGE 2

// Quadrature edges in one detent. A press held past LONG_PRESS_MS is a long
// press, and button edges closer than BUTTON_DEBOUNCE_MS are ignored.
#define PF_SHARED_EDGES_PER_DETENT 4
#define PF_SHARED_LONG_PRESS_MS 1000
#define PF_SHARED_BUTTON_DEBOUNCE_MS 50

// The absolute bus speaks integers 0 to BUS_MAX. A pattern that reads only knob
// deltas gets one click for every BUS_UNITS_PER_CLICK a held channel moves.
#define PF_SHARED_BUS_MAX 1000
#define PF_SHARED_BUS_UNITS_PER_CLICK 10
29 changes: 29 additions & 0 deletions shared/constants.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"//": "Values the firmware and the site both carry. Change one here, then run npm run gen:shared in web/. It writes firmware/patternflow/src/core_shared.h and web/src/lib/shared/constants.ts, and check:shared fails while either is stale.",
"panel": {
"//": "The geometries PANEL_GEOMETRY picks from in config.h. Two 64x64 modules in a chain make the same 128x64 canvas as one 128x64 module.",
"PANEL_GEOM_DEFAULT": "128x64_SINGLE",
"PANEL_GEOMETRIES": {
"128x64_SINGLE": { "id": 1, "physW": 128, "physH": 64, "chain": 1 },
"64x64_SINGLE": { "id": 2, "physW": 64, "physH": 64, "chain": 1 },
"64x64_CHAIN2": { "id": 3, "physW": 64, "physH": 64, "chain": 2 }
}
},
"knobs": {
"//": "The PEC11R-4220F-S0024 has 24 detents a turn. Two turns cross a parameter's whole range, so 48 detents do.",
"KNOB_COUNT": 4,
"DETENTS_PER_TURN": 24,
"TURNS_PER_FULL_RANGE": 2
},
"encoder": {
"//": "Quadrature edges in one detent. A press held past LONG_PRESS_MS is a long press, and button edges closer than BUTTON_DEBOUNCE_MS are ignored.",
"EDGES_PER_DETENT": 4,
"LONG_PRESS_MS": 1000,
"BUTTON_DEBOUNCE_MS": 50
},
"bus": {
"//": "The absolute bus speaks integers 0 to BUS_MAX. A pattern that reads only knob deltas gets one click for every BUS_UNITS_PER_CLICK a held channel moves.",
"BUS_MAX": 1000,
"BUS_UNITS_PER_CLICK": 10
}
}
2 changes: 2 additions & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"check:ci": "npm run check:license && npm run check:moderation && npm run check:headermod && npm run check:hidemod && npm run check:retention && npm run check:deck && npm run check:pack && npm run check:sharing && npm run check:publish && npm run check:notify && npm run check:ports && npm run check:workshop && npm run check:sandbox && npm run check:capture && npm run check:thumbs && npm run check:flatten && npm run check:labedit && npm run check:performance && npm run check:pixelcode && npm run check:director && npm run check:pfstvectors && npm run check:ramp && npm run check:panels && npm run check:sandbox-sync && npm run check:modcache && npm run check:drift",
"build:sandbox": "tsx scripts/build-sandbox.ts",
"check:sandbox-sync": "tsx scripts/build-sandbox.ts --check",
"gen:shared": "tsx scripts/gen-shared.ts",
"doc-map": "tsx scripts/doc-map.ts",
"check:drift": "tsx scripts/drift.ts",
"check:links-absolute": "tsx scripts/drift.ts links-absolute",
Expand All @@ -52,6 +53,7 @@
"check:presets": "tsx scripts/drift.ts presets",
"check:roadmap": "tsx scripts/drift.ts roadmap",
"check:changelog": "tsx scripts/drift.ts changelog",
"check:shared": "tsx scripts/drift.ts shared",
"check:human-lock": "tsx scripts/human-lock.ts",
"check:voice": "tsx scripts/voice-lint.ts"
},
Expand Down
58 changes: 58 additions & 0 deletions web/scripts/drift/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* check:shared: the two files gen:shared writes from shared/constants.json,
* against what it writes now. A hand edit to core_shared.h or constants.ts
* fails here, and so does a change to the JSON nobody regenerated.
*
* Two copies cannot be generated, so this reads them. abi/pf_params.h clamps
* the bus to a literal 1000 and is frozen: installed modules read it without
* recompiling. build_module.py names the panel's 128 and 64 itself, and the
* module cache hashes it. The copies written for other readers, the REST
* contract's 24 and the Ableton bridge's 48, stay by hand and are not read.
*/
import fs from "node:fs";
import path from "node:path";

import { render, SOURCE } from "../gen-shared";
import { compare, done, read, ROOT } from "./_lib";

let stale = 0;
for (const [file, want] of Object.entries(render())) {
const have = fs.existsSync(path.join(ROOT, file)) ? read(file) : "";
if (have === want) {
console.log(`${file}: same as gen:shared writes`);
continue;
}
stale++;
console.log(`${file}: stale, run npm run gen:shared in web/`);
const [a, b] = [want.split("\n"), have.split("\n")];
const lines = [...Array(Math.max(a.length, b.length)).keys()].filter((i) => a[i] !== b[i]);
for (const i of lines.slice(0, 10)) {
console.log(` line ${i + 1}, gen:shared writes: ${a[i] ?? "(no line)"}`);
console.log(` line ${i + 1}, the file has: ${b[i] ?? "(no line)"}`);
}
if (lines.length > 10) console.log(` and ${lines.length - 10} more lines`);
}

const data = JSON.parse(read(SOURCE));

const ABI = "firmware/patternflow/abi/pf_params.h";
const body = read(ABI)
.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, "")
.match(/\bunit1000\s*\([^)]*\)\s*\{([^}]*)\}/)?.[1];
if (body === undefined) console.log(`no unit1000() in ${ABI}`);
compare(
"the bus maximum",
{ label: `${SOURCE} BUS_MAX`, items: [String(data.bus.BUS_MAX)] },
{ label: `the literals in unit1000(), ${ABI}`, items: new Set(body?.match(/\b\d+(\.\d+)?f?\b/g)?.map((n) => String(parseFloat(n)))) },
);

const BUILD = "firmware/toolchain/build_module.py";
const panel = data.panel.PANEL_GEOMETRIES[data.panel.PANEL_GEOM_DEFAULT];
compare(
"the default panel's canvas",
{ label: `${SOURCE} ${data.panel.PANEL_GEOM_DEFAULT}`, items: [`PANEL_W ${panel.physW * panel.chain}`, `PANEL_H ${panel.physH}`] },
{ label: BUILD, items: [...read(BUILD).matchAll(/^(PANEL_[WH]) = (\d+)/gm)].map((m) => `${m[1]} ${m[2]}`) },
);

done();
if (stale) process.exitCode = 1;
106 changes: 106 additions & 0 deletions web/scripts/gen-shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* gen:shared: writes shared/constants.json out as the two files that read it,
* firmware/patternflow/src/core_shared.h and web/src/lib/shared/constants.ts
* (docs/rfc-cleanup.md §2.5). The lab counted 20 detents a turn until 31 July
* 2026, against the encoder's 24, and the community sandbox until 4 September.
* Each copy was found and fixed by hand.
*
* A key in the JSON is the constant's name on both sides: DETENTS_PER_TURN is
* PF_SHARED_DETENTS_PER_TURN in the header and DETENTS_PER_TURN in the module.
* The prefix keeps the macros clear of the names config.h already defines, and
* of every feature's PF_<NAME>_ flags, which check_boundaries.py refuses in
* src/. A key "//" is a note, and each group's note goes over its lines.
*
* check:shared (scripts/drift/shared.ts) imports render() and compares, so the
* files on disk must be exactly what this writes.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
export const SOURCE = "shared/constants.json";
export const HEADER = "firmware/patternflow/src/core_shared.h";
export const MODULE = "web/src/lib/shared/constants.ts";

type Group = Record<string, unknown>;

const fail = (what: string): never => {
throw new Error(`${SOURCE}: ${what}`);
};
const whole = (v: unknown, at: string) =>
Number.isInteger(v) && (v as number) > 0 ? (v as number) : fail(`${at} is ${JSON.stringify(v)}, not a whole number above 0`);

/** A note as comment lines, wrapped at 80 columns. */
function comment(note: unknown): string[] {
if (typeof note !== "string") return [];
const lines: string[] = [];
let line = "//";
for (const word of note.split(/\s+/)) {
if (line !== "//" && line.length + 1 + word.length > 80) {
lines.push(line);
line = "//";
}
line += ` ${word}`;
}
return [...lines, line];
}

const BANNER = [
`// GENERATED by web/scripts/gen-shared.ts from ${SOURCE}. Do not edit.`,
...comment("Change the JSON and run `npm run gen:shared` in web/. check:shared fails while this file says anything else."),
];

/** Both files as gen:shared writes them, keyed by their path from the repository root. */
export function render(json = fs.readFileSync(path.join(ROOT, SOURCE), "utf8")): Record<string, string> {
const data = JSON.parse(json) as Record<string, Group>;
const h = [...BANNER, "//", "// License: MIT", "#pragma once"];
const ts = [...BANNER];
const names = new Set(["PANEL_GEOMETRIES", "PANEL_GEOM_DEFAULT"]);

const panel = data.panel ?? fail("no panel group");
const geometries = Object.entries((panel.PANEL_GEOMETRIES ?? fail("no panel.PANEL_GEOMETRIES")) as Record<string, Group>);
const fallback = panel.PANEL_GEOM_DEFAULT;
if (!geometries.some(([name]) => name === fallback)) fail(`panel.PANEL_GEOM_DEFAULT ${JSON.stringify(fallback)} is not in PANEL_GEOMETRIES`);
const ids = new Set<number>();
h.push("", ...comment(panel["//"]));
ts.push("", ...comment(panel["//"]), "export const PANEL_GEOMETRIES = {");
for (const [name, g] of geometries) {
if (!/^\w+$/.test(name)) fail(`the geometry ${JSON.stringify(name)} is not a C name`);
const at = `panel.PANEL_GEOMETRIES.${name}`;
const [id, physW, physH, chain] = (["id", "physW", "physH", "chain"] as const).map((k) => whole(g[k], `${at}.${k}`));
if (ids.has(id)) fail(`${at}.id ${id} is taken by another geometry`);
ids.add(id);
// config.h refuses a build where these two disagree with the physical size.
const fields = { physW, physH, chain, resW: physW * chain, resH: physH };
h.push(`#define PF_SHARED_PANEL_GEOM_${name} ${id}`);
for (const [k, v] of Object.entries(fields)) h.push(`#define PF_SHARED_PANEL_GEOM_${name}_${k.replace(/[A-Z]/g, "_$&").toUpperCase()} ${v}`);
ts.push(` "${name}": { id: ${id}, ${Object.entries(fields).map(([k, v]) => `${k}: ${v}`).join(", ")} },`);
}
h.push(`#define PF_SHARED_PANEL_GEOM_DEFAULT PF_SHARED_PANEL_GEOM_${fallback}`);
ts.push("} as const;", `export const PANEL_GEOM_DEFAULT = "${fallback}";`);

for (const [group, values] of Object.entries(data)) {
if (group === "//" || group === "panel") continue;
h.push("", ...comment(values["//"]));
ts.push("", ...comment(values["//"]));
for (const [name, v] of Object.entries(values)) {
if (name === "//") continue;
if (!/^[A-Z][A-Z0-9_]*$/.test(name)) fail(`${group}.${name} is not a constant's name`);
if (names.has(name)) fail(`${group}.${name} is named twice`);
names.add(name);
const n = whole(v, `${group}.${name}`);
h.push(`#define PF_SHARED_${name} ${n}`);
ts.push(`export const ${name} = ${n};`);
}
}
return { [HEADER]: `${h.join("\n")}\n`, [MODULE]: `${ts.join("\n")}\n` };
}

if (path.resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) {
for (const [file, text] of Object.entries(render())) {
fs.mkdirSync(path.dirname(path.join(ROOT, file)), { recursive: true });
fs.writeFileSync(path.join(ROOT, file), text);
console.log(`wrote ${file}`);
}
}
29 changes: 29 additions & 0 deletions web/src/lib/shared/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// GENERATED by web/scripts/gen-shared.ts from shared/constants.json. Do not edit.
// Change the JSON and run `npm run gen:shared` in web/. check:shared fails
// while this file says anything else.

// The geometries PANEL_GEOMETRY picks from in config.h. Two 64x64 modules in a
// chain make the same 128x64 canvas as one 128x64 module.
export const PANEL_GEOMETRIES = {
"128x64_SINGLE": { id: 1, physW: 128, physH: 64, chain: 1, resW: 128, resH: 64 },
"64x64_SINGLE": { id: 2, physW: 64, physH: 64, chain: 1, resW: 64, resH: 64 },
"64x64_CHAIN2": { id: 3, physW: 64, physH: 64, chain: 2, resW: 128, resH: 64 },
} as const;
export const PANEL_GEOM_DEFAULT = "128x64_SINGLE";

// The PEC11R-4220F-S0024 has 24 detents a turn. Two turns cross a parameter's
// whole range, so 48 detents do.
export const KNOB_COUNT = 4;
export const DETENTS_PER_TURN = 24;
export const TURNS_PER_FULL_RANGE = 2;

// Quadrature edges in one detent. A press held past LONG_PRESS_MS is a long
// press, and button edges closer than BUTTON_DEBOUNCE_MS are ignored.
export const EDGES_PER_DETENT = 4;
export const LONG_PRESS_MS = 1000;
export const BUTTON_DEBOUNCE_MS = 50;

// The absolute bus speaks integers 0 to BUS_MAX. A pattern that reads only knob
// deltas gets one click for every BUS_UNITS_PER_CLICK a held channel moves.
export const BUS_MAX = 1000;
export const BUS_UNITS_PER_CLICK = 10;
Loading