diff --git a/.changeset/io-positions-enrichment.md b/.changeset/io-positions-enrichment.md new file mode 100644 index 000000000..9bd62efc5 --- /dev/null +++ b/.changeset/io-positions-enrichment.md @@ -0,0 +1,20 @@ +--- +"catalog": minor +--- + +Add IO port positioning (`columnPosition` / `rowPosition`) enrichment. + +Starts issue #212: IO ports can now carry `columnPosition` (left-to-right) and +`rowPosition` (top-to-bottom) values describing their spatial arrangement on a +device edge, so the setup graph can render port layouts accurately. The schema +already accepted these fields; this adds the convention, tooling, and first data. + +- New `pnpm enrich-io ` interactive tool to assign column/row positions to a + hardware entry's IO ports, writing back to YAML while preserving formatting. +- Document the ordering convention in `schema/CONTEXT.md` (via the generator) and + `CLAUDE.md`. +- Fully correct and backfill the Eventide H90 Harmonizer as the validation case: + its IO now lists all 14 discrete rear-panel jacks (Inputs 1–4, Outputs 1–4, + Exp/Ctl 1–2, MIDI In, MIDI Out/Thru, USB-C, Power) with column/row positions, + replacing an incomplete 5-entry list that omitted the audio I/O and MIDI Out and + mis-positioned the jacks. diff --git a/CLAUDE.md b/CLAUDE.md index 129df9f07..3003389c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,6 +117,20 @@ preamp/mixer outputs; a powered speaker input is never `line`. (guitars/basses), `position`. Every product entry must include `primaryCategory`. These are enforced by `pnpm validate`, not just review. +**Port ordering (optional):** `columnPosition` and `rowPosition` place a port +within its `position` edge so the setup graph can render the layout. Both are +1-based, numbered independently per edge, and go right after `position`: +`columnPosition` is left-to-right (column 1 = leftmost, viewing that face +head-on); `rowPosition` is top-to-bottom (row 1 = topmost). Stacked ports share a +`columnPosition` and differ by `rowPosition`; a single-row edge uses +`rowPosition: 1`. This is visual info from photos/manuals — assign it with +`pnpm enrich-io `, not during bulk import. + +One `io` entry per **physical** jack (`maxConnections: 1`). Do not collapse an L/R +pair into one stereo entry with `maxConnections: 2`, and do not split one jack in +two — bulk imports get this wrong. E.g. the Eventide H90 has 14 discrete jacks +(Inputs 1–4, Outputs 1–4, Exp/Ctl 1–2, MIDI In, MIDI Out/Thru, USB-C, Power). + ## Content Entries Content entries (presets, sample packs, expansions) live in `data/content/` as a separate collection. Content `primaryCategory` values include `preset`, `preset-pack`, `sample-pack`, `drum-sample-pack`, `loop-pack`, `sound-library`, `soundfont`, `impulse-response`, and `multisample`. diff --git a/data/hardware/eventide-audio-h90-harmonizer.yaml b/data/hardware/eventide-audio-h90-harmonizer.yaml index 8778771d9..84442e9e0 100644 --- a/data/hardware/eventide-audio-h90-harmonizer.yaml +++ b/data/hardware/eventide-audio-h90-harmonizer.yaml @@ -7,41 +7,174 @@ description: A next-generation multi-effects powerhouse with 74 algorithms and comprehensive I/O, designed for guitar, bass, synths, and more, offering flexible routing and extensive sound possibilities. io: - - name: Expression Input 1 + # Most jacks are on the rear (Top) edge: Inputs and Outputs in 2x2 grids + # (columns left-to-right, rows top-to-bottom), then Exp/Ctl, USB-C, and Power + # in a single row to their right. The MIDI In and Out/Thru jacks are on the + # Left side of the pedal. See the H90 Quick Reference Guide. + # Audio jacks are 1/4-inch TS; each in/out can be toggled between instrument + # and line level in the H90 software (the rear-panel "Line Lvl" LEDs indicate + # the current setting). `type` records the typical default; the toggle is noted + # in each port's description. + - name: Input 1 signalFlow: input category: audio - type: cv/gate + type: instrument connection: 1/4-inch + connectorDetail: + - TS maxConnections: 1 - position: Bottom - - name: Expression Input 2 + position: Top + columnPosition: 1 + rowPosition: 1 + description: Instrument or line level, switchable in software. + - name: Input 2 signalFlow: input category: audio - type: cv/gate + type: instrument connection: 1/4-inch + connectorDetail: + - TS maxConnections: 1 - position: Bottom - - name: MIDI in + position: Top + columnPosition: 1 + rowPosition: 2 + description: Instrument or line level, switchable in software. + - name: Input 3 (Insert Return / Dual Path 2) signalFlow: input - category: midi - type: midi - connection: 5-pin din + category: audio + type: instrument + connection: 1/4-inch + connectorDetail: + - TS maxConnections: 1 - position: Bottom - - name: USB Control + position: Top + columnPosition: 2 + rowPosition: 1 + description: Insert return or Dual Path 2 input. Instrument or line level, + switchable in software. + - name: Input 4 (Insert Return / Dual Path 2) signalFlow: input + category: audio + type: instrument + connection: 1/4-inch + connectorDetail: + - TS + maxConnections: 1 + position: Top + columnPosition: 2 + rowPosition: 2 + description: Insert return or Dual Path 2 input. Instrument or line level, + switchable in software. + - name: Output 1 + signalFlow: output + category: audio + type: line + connection: 1/4-inch + connectorDetail: + - TS + maxConnections: 1 + position: Top + columnPosition: 3 + rowPosition: 1 + description: Instrument or line level, switchable in software. + - name: Output 2 + signalFlow: output + category: audio + type: line + connection: 1/4-inch + connectorDetail: + - TS + maxConnections: 1 + position: Top + columnPosition: 3 + rowPosition: 2 + description: Instrument or line level, switchable in software. + - name: Output 3 (Insert Send / Dual Path 2) + signalFlow: output + category: audio + type: line + connection: 1/4-inch + connectorDetail: + - TS + maxConnections: 1 + position: Top + columnPosition: 4 + rowPosition: 1 + description: Insert send or Dual Path 2 output. Instrument or line level, + switchable in software. + - name: Output 4 (Insert Send / Dual Path 2) + signalFlow: output + category: audio + type: line + connection: 1/4-inch + connectorDetail: + - TS + maxConnections: 1 + position: Top + columnPosition: 4 + rowPosition: 2 + description: Insert send or Dual Path 2 output. Instrument or line level, + switchable in software. + - name: Exp/Ctl 1 + signalFlow: input + category: audio + type: expression + connection: 1/4-inch + connectorDetail: + - TRS + maxConnections: 1 + position: Top + columnPosition: 5 + rowPosition: 1 + - name: Exp/Ctl 2 + signalFlow: input + category: audio + type: expression + connection: 1/4-inch + connectorDetail: + - TRS + maxConnections: 1 + position: Top + columnPosition: 5 + rowPosition: 2 + - name: USB-C + signalFlow: bidirectional category: digital type: usb connection: usb-c maxConnections: 1 - position: Bottom - - name: Power Input + position: Top + columnPosition: 6 + rowPosition: 1 + - name: Power signalFlow: input category: power type: power connection: barrel + connectorDetail: + - center-positive + maxConnections: 1 + position: Top + columnPosition: 7 + rowPosition: 1 + - name: MIDI In + signalFlow: input + category: midi + type: midi + connection: 5-pin din + maxConnections: 1 + position: Left + columnPosition: 1 + rowPosition: 1 + - name: MIDI Out/Thru + signalFlow: output + category: midi + type: midi + connection: 5-pin din maxConnections: 1 - position: Bottom + position: Left + columnPosition: 2 + rowPosition: 1 specs: |- - 74 professional algorithms - Dual algorithm design diff --git a/package.json b/package.json index 531590ac3..13cc4954d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "version": "changeset version", "release": "changeset publish", "assign-ids": "tsx scripts/assign-ids.ts", + "enrich-io": "tsx scripts/enrich-io.ts", "format": "tsx scripts/format-yaml.ts", "format:check": "prettier --check \"data/**/*.yaml\"", "validate-urls": "tsx scripts/validate-urls.ts", diff --git a/schema/CONTEXT.md b/schema/CONTEXT.md index adebec1d3..609c68a9b 100644 --- a/schema/CONTEXT.md +++ b/schema/CONTEXT.md @@ -782,6 +782,21 @@ Accessory entries (cables, stands, acoustic treatment) live in `data/accessories **Aliases:** +### Port Ordering: columnPosition / rowPosition (optional) + +`columnPosition` and `rowPosition` describe the spatial arrangement of ports +**within a single `position` edge**, so the setup graph can render port layouts +accurately. Both are optional 1-based integers; numbering is independent per edge. + +- `columnPosition`: left-to-right order, viewing that face head-on. Column 1 = leftmost. +- `rowPosition`: top-to-bottom order, viewing that face head-on. Row 1 = topmost. +- Ports stacked vertically share a `columnPosition` and differ by `rowPosition`. + A single-row edge uses `rowPosition: 1` on every port. +- Place `columnPosition`/`rowPosition` immediately after `position`. + +These are visual/spatial values best read off product photos or the manufacturer's +rear-panel diagram — assign them with `pnpm enrich-io `, not during bulk import. + ### IO Types (advisory — unknown values produce warnings) - `line` diff --git a/scripts/enrich-io.ts b/scripts/enrich-io.ts new file mode 100644 index 000000000..0248889a5 --- /dev/null +++ b/scripts/enrich-io.ts @@ -0,0 +1,233 @@ +/** + * Interactive IO Position Enrichment + * + * Assigns `columnPosition` / `rowPosition` to a hardware entry's IO ports so the + * Studio setup graph can render the physical port layout. See issue #212. + * + * These values are visual/spatial information best read off product photos or the + * manufacturer's rear-panel diagram, so this is a manual enrichment pass rather + * than part of bulk import. + * + * Conventions (also documented in schema/CONTEXT.md): + * - columnPosition: 1-based left-to-right order of ports on a given `position` + * edge, viewing that face head-on. Column 1 = leftmost. + * - rowPosition: 1-based top-to-bottom order, viewing that face head-on. + * Row 1 = topmost. Single-row edges use rowPosition 1. + * - Numbering is independent per `position` edge. + * + * Usage: + * pnpm enrich-io [--dry-run] [--open] + * + * hardware filename without .yaml (e.g. eventide-audio-h90-harmonizer) + * --dry-run print the changes without writing the file + * --open open the product URL in a browser (macOS) to view port photos + * + * At each prompt: enter a positive integer, press Enter to keep the current + * value, or type `-` to clear the value. + */ + +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { stdin as input, stdout as output } from "node:process"; +import readline from "node:readline/promises"; +import { isMap, isSeq, type Pair, parseDocument, type Scalar, type YAMLMap } from "yaml"; +import { SLUG_PATTERN } from "./lib/schema-loader.js"; +import { DATA_DIR } from "./lib/utils.js"; + +interface IOPortView { + name?: string; + signalFlow?: string; + position?: string; + columnPosition?: number; + rowPosition?: number; +} + +interface ChangeRecord { + name: string; + field: "columnPosition" | "rowPosition"; + from: number | undefined; + to: number | undefined; +} + +/** Resolve the string value of a Pair's key. */ +function pairKey(pair: Pair): string | undefined { + const key = pair.key as Scalar | string | null | undefined; + if (key && typeof key === "object" && "value" in key) { + return key.value == null ? undefined : String(key.value); + } + return key == null ? undefined : String(key); +} + +/** + * Set (or clear) a numeric field on a port map, keeping it in canonical order: + * the field is moved to immediately after the first present key in `afterKeys`. + * Returns true if the document was modified. + */ +function applyField( + portNode: YAMLMap, + key: string, + newValue: number | undefined, + afterKeys: string[] +): boolean { + const items = portNode.items; + const currentIdx = items.findIndex((it) => pairKey(it) === key); + const existing = currentIdx >= 0 ? portNode.get(key) : undefined; + + if (newValue === undefined) { + if (currentIdx >= 0) { + portNode.delete(key); + return true; + } + return false; + } + + if (existing === newValue) return false; + + portNode.set(key, newValue); + + // Re-locate the (possibly newly-appended) key to just after its anchor. + const idx = items.findIndex((it) => pairKey(it) === key); + const [item] = items.splice(idx, 1); + let insertAt = 0; + for (const anchor of afterKeys) { + const anchorIdx = items.findIndex((it) => pairKey(it) === anchor); + if (anchorIdx >= 0) { + insertAt = anchorIdx + 1; + break; + } + } + items.splice(insertAt, 0, item); + return true; +} + +/** + * Prompt for a positive integer; Enter keeps current, `-` clears, EOF keeps. + * + * Reads via `nextLine` (backed by readline's async iterator) rather than + * `rl.question()` so piped / non-TTY input is consumed line-by-line without + * dropping lines. + */ +async function askNumber( + nextLine: () => Promise, + label: string, + current: number | undefined +): Promise { + output.write(` ${label} [${current ?? "-"}]: `); + const raw = await nextLine(); + if (raw === undefined) { + output.write("\n"); + return current; // EOF — keep existing value + } + const answer = raw.trim(); + if (answer === "") return current; + if (answer === "-" || answer.toLowerCase() === "none") return undefined; + const n = Number.parseInt(answer, 10); + if (!Number.isInteger(n) || n < 1 || String(n) !== answer) { + console.log(" ! must be a positive integer — keeping current value"); + return current; + } + return n; +} + +async function main(): Promise { + const args = process.argv.slice(2); + const dryRun = args.includes("--dry-run"); + const openImages = args.includes("--open"); + const slugArg = args.find((a) => !a.startsWith("--")); + + if (!slugArg) { + console.error("Usage: pnpm enrich-io [--dry-run] [--open]"); + process.exit(1); + } + + const slug = slugArg.replace(/\.ya?ml$/, ""); + if (!SLUG_PATTERN.test(slug)) { + console.error( + `Invalid slug '${slug}'. Slugs are lowercase letters, numbers, and hyphens ` + + "(no path separators)." + ); + process.exit(1); + } + const file = path.join(DATA_DIR, "hardware", `${slug}.yaml`); + if (!fs.existsSync(file)) { + console.error(`No hardware entry found at data/hardware/${slug}.yaml`); + process.exit(1); + } + + const doc = parseDocument(fs.readFileSync(file, "utf-8")); + const ioSeq = doc.get("io"); + if (!isSeq(ioSeq) || ioSeq.items.length === 0) { + console.error(`${slug} has no 'io' entries to enrich.`); + process.exit(1); + } + + const data = doc.toJSON() as { name?: string; url?: string }; + console.log(`\n${data.name ?? slug}`); + if (data.url) { + console.log(` ${data.url}`); + if (openImages && process.platform === "darwin") { + execFile("open", [data.url], () => {}); + } + } + console.log( + " Assign columnPosition (left→right) and rowPosition (top→bottom) per edge.\n" + + " Enter a number, press Enter to keep, or type '-' to clear.\n" + ); + + const rl = readline.createInterface({ input, output }); + const lineIterator = rl[Symbol.asyncIterator](); + const nextLine = async (): Promise => { + const { value, done } = await lineIterator.next(); + return done ? undefined : (value as string); + }; + const changes: ChangeRecord[] = []; + + for (let i = 0; i < ioSeq.items.length; i++) { + const portNode = ioSeq.items[i]; + if (!isMap(portNode)) continue; + const port = portNode.toJSON() as IOPortView; + const name = port.name ?? `port ${i + 1}`; + + console.log( + `[${i + 1}/${ioSeq.items.length}] ${name} — ${port.signalFlow ?? "?"}, ` + + `position: ${port.position ?? "(none)"}` + ); + + const col = await askNumber(nextLine, "columnPosition", port.columnPosition); + const row = await askNumber(nextLine, "rowPosition", port.rowPosition); + + if (applyField(portNode, "columnPosition", col, ["position"])) { + changes.push({ name, field: "columnPosition", from: port.columnPosition, to: col }); + } + if (applyField(portNode, "rowPosition", row, ["columnPosition", "position"])) { + changes.push({ name, field: "rowPosition", from: port.rowPosition, to: row }); + } + } + + rl.close(); + + if (changes.length === 0) { + console.log("\nNo changes."); + return; + } + + console.log(`\n${changes.length} change(s):`); + for (const c of changes) { + console.log(` ${c.name}: ${c.field} ${c.from ?? "-"} → ${c.to ?? "-"}`); + } + + if (dryRun) { + console.log("\n(dry run — file not written)"); + return; + } + + fs.writeFileSync(file, doc.toString()); + console.log(`\n✓ Wrote data/hardware/${slug}.yaml`); + console.log(" Next: run 'pnpm format' then 'pnpm validate'."); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/generate-context.ts b/scripts/generate-context.ts index 9f1c438a0..5ee30c776 100644 --- a/scripts/generate-context.ts +++ b/scripts/generate-context.ts @@ -273,6 +273,21 @@ ${Object.entries(context.ioPositionAliases) .map(([alias, canonical]) => `- \`${alias}\` → \`${canonical}\``) .join("\n")} +### Port Ordering: columnPosition / rowPosition (optional) + +\`columnPosition\` and \`rowPosition\` describe the spatial arrangement of ports +**within a single \`position\` edge**, so the setup graph can render port layouts +accurately. Both are optional 1-based integers; numbering is independent per edge. + +- \`columnPosition\`: left-to-right order, viewing that face head-on. Column 1 = leftmost. +- \`rowPosition\`: top-to-bottom order, viewing that face head-on. Row 1 = topmost. +- Ports stacked vertically share a \`columnPosition\` and differ by \`rowPosition\`. + A single-row edge uses \`rowPosition: 1\` on every port. +- Place \`columnPosition\`/\`rowPosition\` immediately after \`position\`. + +These are visual/spatial values best read off product photos or the manufacturer's +rear-panel diagram — assign them with \`pnpm enrich-io \`, not during bulk import. + ### IO Types (advisory — unknown values produce warnings) ${context.ioTypes.map((v) => `- \`${v}\``).join("\n")}