diff --git a/firmware/patternflow/src/core_shared.h b/firmware/patternflow/src/core_shared.h new file mode 100644 index 00000000..4a7f65ae --- /dev/null +++ b/firmware/patternflow/src/core_shared.h @@ -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 diff --git a/shared/constants.json b/shared/constants.json new file mode 100644 index 00000000..7fce1275 --- /dev/null +++ b/shared/constants.json @@ -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 + } +} diff --git a/web/package.json b/web/package.json index ec4bc6b2..a5a0fa4b 100644 --- a/web/package.json +++ b/web/package.json @@ -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", @@ -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" }, diff --git a/web/scripts/drift/shared.ts b/web/scripts/drift/shared.ts new file mode 100644 index 00000000..c8b32504 --- /dev/null +++ b/web/scripts/drift/shared.ts @@ -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; diff --git a/web/scripts/gen-shared.ts b/web/scripts/gen-shared.ts new file mode 100644 index 00000000..e28df517 --- /dev/null +++ b/web/scripts/gen-shared.ts @@ -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__ 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; + +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 { + const data = JSON.parse(json) as Record; + 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); + 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(); + 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}`); + } +} diff --git a/web/src/lib/shared/constants.ts b/web/src/lib/shared/constants.ts new file mode 100644 index 00000000..e5211834 --- /dev/null +++ b/web/src/lib/shared/constants.ts @@ -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;