From 1c1841747b1524de3afb0e648a106bc9e0700bb2 Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sat, 10 Oct 2026 16:30:12 -0400 Subject: [PATCH 1/6] patcher: TUI patch view, /patch, pane screen, macro knobs src/tui/patch-view.ts: the model (nodes in signal order, ports, a neighbourhood cable matrix), each key's typed patch command, and the paint. tui/patch.ts paints it at every size, NO_COLOR/mono and ASCII, with the compiler's dropped-cable warnings. /patch [--fx ] [off] opens it in place of the highway; dawg pane patch [track] [fx] opens a pane on it. Sound > patch and Effects > patch in Ctrl-K; the first four macros are the four knobs (KNOB_MAPS sound:patch, with a #n path segment). KEYS.patch, help rows, size-matrix scenario, DAWG.md. Co-Authored-By: Claude Opus 5.5 --- DAWG.md | 3 + guides/panes.md | 2 +- src/commands/grammar.ts | 1 + src/commands/help.test.ts | 1 + src/commands/help.ts | 7 +- src/launch-args.test.ts | 10 + src/launch-args.ts | 12 +- src/main.ts | 165 +++++- src/tui/knob-fields.ts | 7 +- src/tui/knob-map.test.ts | 4 +- src/tui/knob-map.ts | 17 +- src/tui/menu.ts | 7 +- src/tui/patch-menu.ts | 252 ++++++++- src/tui/patch-view.test.ts | 390 ++++++++++++++ src/tui/patch-view.ts | 1012 ++++++++++++++++++++++++++++++++++++ test/pty-harness.ts | 2 +- test/sizes-lib.ts | 13 + tui/app.ts | 18 +- tui/grammar.ts | 23 + tui/patch.ts | 567 ++++++++++++++++++++ 20 files changed, 2471 insertions(+), 42 deletions(-) create mode 100644 src/tui/patch-view.test.ts create mode 100644 src/tui/patch-view.ts create mode 100644 tui/patch.ts diff --git a/DAWG.md b/DAWG.md index deac12fc..3f603290 100644 --- a/DAWG.md +++ b/DAWG.md @@ -1663,6 +1663,8 @@ Copy, move, clear and reverse never touch tempo or meter points (except that `in `Ctrl-T` or `/tape` opens TAPE in place of the highway: every track as a row across the bars, each cell a density glyph (` ▁▂▃▄▅▆▇█`, the notes starting there weighted by velocity; a held note `▁`, an audio clip `▃`, empty `·`). Above the rows sit the bar ruler with the playhead `▼` and the loop brackets `[══]` read from `score.loop`, and the sections (`▀name▀`, the looped one reversed). The focused row carries `›`, a muted row is dim, and color is never the only cue (NO_COLOR and the mono theme keep every glyph). `-` and `=` zoom between a bar, a beat and half a beat a cell, and the view scrolls to keep the playhead in sight. The highway stays home: `Esc` or `Ctrl-T` again goes back, and `dawg pane tape` opens a pane on it. +`/patch` (Ctrl-K Sound › patch › Edit patch) opens the patch view of the focused track in place of the highway; `/patch --fx ` opens one of its effect patches (Effects › " patch" › Edit effect patch). It lists the nodes in signal order (`◆` per voice, `●` once), the selected node's ports, and a cable matrix with outputs down and inputs across, pinned headers, and only the selected node's neighbourhood until `f` shows the whole matrix. A cell shows a cable's amount; a cable the compiler drops (an unreachable node, a cycle) is marked and its warning sits above the knobs. `tab` moves between the panes, `enter` opens a node's settings in the drawer or wires and unwires a cell, `[ ] { }` and `1`-`0` set a cable's amount, and `a`, `w`, `x`, `m` and `g` add a node, wire to a legal input, remove, map a param to a knob, and switch a node between per voice and once. Every key runs a typed `patch …` command that echoes, so the receipt teaches the words. The patch's first four macros are the four knobs (`KNOB_MAPS` row `sound:patch`). A track that is not a patch shows as a preview of its engine (`patch convert` makes it one), and a library patch is read-only apart from its knobs. `Esc` goes back, and `dawg pane patch [track]` opens a pane on it. + The footer is the range line (`range: bass · bars 5–6 (loop)`, the loop range, else the section, else the bar under the playhead) with the clipboard chip, the four knobs (● playhead, ▲ loop length, ■ tempo, ◆ volume, from `tui/knobs.ts`; `↑` `↓` pick, `←` `→` turn, `⇧` coarse, `Enter` opens the drawer) and the key hints. Every key echoes and runs a typed command, so TAPE teaches the range commands above: `c` (`C` all tracks) is `copy bass 5-6`, `x` is `copy` then `clear`, `v` pastes at the playhead's bar (`V` inserts), `delete` clears, `~` reverses, `\` loops the section here (again: off), `<` `>` slide the loop, `[` `]` `{` `}` move its end and start, `,` `.` jump between sections, `s` `S` split and join the section, `h` `H` mute and solo, and `1`-`9` or `Tab` focus a track row (number keys pick tracks on TAPE, not knobs). The first paste after a cut lands as one `move`, one revision. The clipboard is per pane and kept in `.dawg/clipboard/` next to the session, so it survives a restart. With the mouse, a click on the ruler jumps, a drag on it sets the loop, a click on a row focuses it and a drag along a row sets the loop over those bars; the wheel walks the playhead a bar at a time. Other panes on a track show in the right gutter (`B`, `C●` while that pane records). Score edits commit like any other command, so other windows and the agent see them. With a form set (`form verse chorus*2`), TAPE draws the song unrolled, in the order it plays: the first pass of each section is solid and every later pass is a ghost, shaded `░` (`~` without Unicode) and dimmed, with the section row labeled `chorus ×2`. Each column still names its score bar, so a key, click or drag on a ghost acts on the source section, and the range line says so (`range: bass · bars 5–8 (chorus) · edits chorus (plays 2×)`). The playhead follows the pass that is playing; bars the form never plays sit after it. `form print` (or `form bake`) writes the form out as plain bars: `printed form to tape · 16 bars`. @@ -1996,6 +1998,7 @@ Hit-testing uses the same paint pass that draws the frame: each painter records | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | highway | lanes stop at 10 columns; a wider highway shows more pitches (one to three octaves) and centers the rest; a taller one looks further ahead (at most 4 rows per beat) | | TAPE | more bars across and every track down, until the song runs out | +| patch view | the node list widens to 44 columns and the ports to 64; a wider or taller matrix shows more cables, then the whole patch | | drawer (knobs, mixer) | rows stop at 96 columns; a wide drawer lists every param of the page beside the four knobs (what Tab shows); a taller drawer fits more mixer tracks | | knob strip | each slot at most 40 columns, left-aligned | | help, guides, menus, transcript, `?` keys | a panel at most 100 columns of text, left-aligned; with 36 or more columns free, the song keeps drawing beside it | diff --git a/guides/panes.md b/guides/panes.md index 05d2028c..cf6d3508 100644 --- a/guides/panes.md +++ b/guides/panes.md @@ -29,7 +29,7 @@ and its own undo. In a shell, open a pane on one screen: -- `dawg pane home|play|tape|sound|menu [track] [pin]` +- `dawg pane home|play|tape|sound|menu|patch [track] [pin]` ## Next diff --git a/src/commands/grammar.ts b/src/commands/grammar.ts index ee4facb3..652d9ea0 100644 --- a/src/commands/grammar.ts +++ b/src/commands/grammar.ts @@ -335,6 +335,7 @@ export const WINDOW_VERBS: ReadonlySet = new Set([ "fitmode", "euclid", "tape", + "patch", "style", "styles", "calibration", diff --git a/src/commands/help.test.ts b/src/commands/help.test.ts index 1c5adf83..9749cafd 100644 --- a/src/commands/help.test.ts +++ b/src/commands/help.test.ts @@ -82,6 +82,7 @@ describe("help reference", () => { "/menu", "/model", "/motion", + "/patch", "/play", "/rename", "/resume", diff --git a/src/commands/help.ts b/src/commands/help.ts index 1bfd5911..40c15fb2 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -163,6 +163,11 @@ export const HELP_SECTIONS: readonly HelpSection[] = [ "seeded feel at render · humanize 10 8 5 · humanize 20 bars 2-3", }, { command: "expression", summary: "this track's performance settings" }, + { + command: "/patch [--fx ] [off]", + summary: + "the patch view: nodes, ports, cables · a add · w wire · m knob", + }, ], }, { @@ -1094,7 +1099,7 @@ export const USAGE: Readonly> = { reverse: "reverse [|all] [-|
] · reverse bass 5-6", jump: "jump [.] |
· jump 5 · jump 5.3 · jump chorus", loop: "loop - |
| next | prev | off · loop 5-6 · loop chorus · loop off", - pane: "pane home|play|tape|sound|menu [] [pin|follow []] · pane tape · pane sound bass pin", + pane: "pane home|play|tape|sound|menu|patch [] [pin|follow []] · pane tape · pane sound bass pin · pane patch bass", follow: "follow [] · follow b · unfollow stops", panes: "panes · who has this session open, on what · pane tape", knobs: diff --git a/src/launch-args.test.ts b/src/launch-args.test.ts index d6ae65eb..3c7d8747 100644 --- a/src/launch-args.test.ts +++ b/src/launch-args.test.ts @@ -262,6 +262,16 @@ describe("dawg pane", () => { pin: true, }); expect(pane(["menu", "mix"])).toMatchObject({ param: "mix" }); + // The patch view: a track, then an effect patch by name. + expect(pane(["patch", "bass"])).toMatchObject({ + screen: "patch", + track: "bass", + param: undefined, + }); + expect(pane(["patch", "bass", "wide-crush"])).toMatchObject({ + track: "bass", + param: "wide-crush", + }); expect(pane(["sound", "follow"])).toMatchObject({ follow: "*" }); expect(pane(["sound", "follow", "b"])).toMatchObject({ follow: "B" }); }); diff --git a/src/launch-args.ts b/src/launch-args.ts index f6cf4770..6e0fb161 100644 --- a/src/launch-args.ts +++ b/src/launch-args.ts @@ -41,7 +41,14 @@ export const BOOLEAN_FLAGS: readonly string[] = [ export type TrackArg = { id: string; name: string }; /** Screens a pane can open on (§12.5). */ -export const PANE_SCREENS = ["home", "play", "tape", "sound", "menu"] as const; +export const PANE_SCREENS = [ + "home", + "play", + "tape", + "sound", + "menu", + "patch", +] as const; export type PaneScreen = (typeof PANE_SCREENS)[number]; /** @@ -177,7 +184,7 @@ const SOUND_PARAMS: readonly string[] = [ ]; const PANE_USAGE = - "usage: dawg pane home|play|tape|sound|menu [track] [volume|pan|section] [pin|follow [A-Z]]"; + "usage: dawg pane home|play|tape|sound|menu|patch [track] [volume|pan|section|fx patch] [pin|follow [A-Z]]"; export function parsePaneArgs(argv: readonly string[]): LaunchParse { const [screenWord, ...rest] = argv; @@ -224,6 +231,7 @@ export function parsePaneArgs(argv: readonly string[]): LaunchParse { (first !== undefined && (screen === "home" || screen === "play" || + screen === "patch" || (screen === "sound" && !SOUND_PARAMS.includes(first.toLowerCase())))); if (trackFirst) { track = normalizeTrackArg(first!); diff --git a/src/main.ts b/src/main.ts index 3151f957..2fba92cf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -357,6 +357,16 @@ import { type TapeContext, } from "./tui/tape-mode.ts"; import { tapeView } from "./tui/tape-view.ts"; +import { + clampState, + patchKey, + patchModel, + patchPaint, + patchViewState, + type PatchViewState, +} from "./tui/patch-view.ts"; +import { nodeMenuId } from "./tui/patch-menu.ts"; +import type { PatchPaint } from "../tui/patch.ts"; import { PromptQueue } from "./tui/prompt-queue.ts"; import { TAPE_ZOOMS, type TapeView, type TapeZoom } from "../tui/tape.ts"; import type { HitTarget } from "../tui/hits.ts"; @@ -885,6 +895,15 @@ const CHORDS_COMMAND = /^\/chords\s+(.+)$/i; let chordScreenWas = false; /** The range clipboard (`copy bass 5-6`, then `paste at 9`); per window. */ let rangeClipboard: RangeClipboard | undefined; +/** + * The patch view (patcher design §7): on, and its pane state (which + * patch, the focused pane, the node, port and matrix cell, the knob). + * Each key runs a typed `patch …` command. + */ +const patchView: { on: boolean; state: PatchViewState } = { + on: false, + state: patchViewState(), +}; /** * TAPE (op1-ux §6): on, its zoom, its knob strip, the last view painted * (mouse hits map cells to bars through it), a ruler drag in progress, and @@ -1208,7 +1227,11 @@ function appView(value: TrackScore, beat: number): AppView { pane: port.pane, types: typesIndicator, play: play?.on ? play.header() : undefined, - tape: tape.on && !play?.on ? currentTapeView(value, beat) : undefined, + tape: + tape.on && !play?.on && !patchView.on + ? currentTapeView(value, beat) + : undefined, + patch: patchView.on && !play?.on ? currentPatchPaint(value) : undefined, loudness: masterLoudness(value), }; } @@ -2112,6 +2135,10 @@ async function runInteractive(): Promise { tick(true); continue; } + if (typeof value === "string" && patchView.on && patchInput(value)) { + tick(true); + continue; + } // Ctrl-T opens TAPE (op1-ux §6); again (or esc) goes home. if (value === "\u0014" && tui.ui.overlay === undefined) { if (tape.on) receipt(exitTape()); @@ -2396,6 +2423,14 @@ async function submit(prompt: string): Promise { `${lines.length} note${lines.length === 1 ? "" : "s"} · ${requestedTrack} · remove `, ); } + const patchViewMatch = command.match( + /^\/patch(?:\s+(on|off)|\s+--fx\s+([a-z][a-z0-9-]{0,31}))?$/i, + ); + if (patchViewMatch) { + const wanted = patchViewMatch[1]?.toLowerCase(); + if (wanted === "off") return exitPatch(); + return enterPatch(patchViewMatch[2]?.toLowerCase()); + } const tapeCommand = command.match(/^\/tape(?:\s+(on|off))?$/i); if (tapeCommand) { const wanted = tapeCommand[1]?.toLowerCase(); @@ -4435,13 +4470,15 @@ function paneView(): PaneView { const view: PaneView = { screen: session?.on ? "play" - : tape.on - ? "tape" - : fader - ? "sound" - : menu.open - ? "menu" - : "home", + : patchView.on + ? "patch" + : tape.on + ? "tape" + : fader + ? "sound" + : menu.open + ? "menu" + : "home", }; if (fader) view.param = fader.label.slice(0, 64); if (session?.on) view.playing = true; @@ -4470,6 +4507,7 @@ async function openPane(pane: PaneArgs): Promise { let result: string | Receipt | undefined; if (pane.screen === "play") result = await enterPlay(); else if (pane.screen === "tape") result = await enterTape(); + else if (pane.screen === "patch") result = await enterPatch(pane.param); else if (pane.screen === "sound") result = await submit(pane.param ?? "volume"); else if (pane.screen === "menu") @@ -4725,6 +4763,7 @@ function keysScreen(): readonly KeySection[] | undefined { if (prompt.value.length > 0) return undefined; if (play?.on) return play.chords.on ? [...KEYS.play, ...KEYS.chords] : KEYS.play; + if (patchView.on) return KEYS.patch; if (tape.on) return KEYS.tape; return KEYS.prompt; } @@ -5729,6 +5768,7 @@ function echoGesture(commands: readonly string[]): void { async function enterTape(): Promise { if (play?.on) await play.exit(); if (tape.on) return ok("tape · esc goes home"); + exitPatch(); closeFader(); if (menu.open) menu.close(); tape.on = true; @@ -5741,6 +5781,115 @@ async function enterTape(): Promise { ); } +/** `/patch [--fx ]`: the patch view on the focused track. */ +async function enterPatch(fx?: string): Promise { + const model = patchModel(score, requestedTrack, fx); + if (typeof model === "string") return fail(model); + if (play?.on) await play.exit(); + closeFader(); + if (menu.open) menu.close(); + if (tape.on) exitTape(); + if (!patchView.on || patchView.state.fx !== fx) + patchView.state = patchViewState(fx); + patchView.on = true; + const patch = model.patch; + const what = model.preview + ? "a preview · patch convert makes it a patch" + : model.ref !== undefined + ? `library ${model.ref} · patch detach edits it` + : `${patch.nodes.length} nodes · ${patch.cables.length} cables`; + return ok( + `patch ${model.track.id} ▸ ${patch.name} · ${what} · ? keys · esc home`, + ); +} + +function exitPatch(): Receipt { + if (!patchView.on) return ok("the patch view is off"); + patchView.on = false; + return ok("home"); +} + +/** The patch view's paint, or undefined when its track or patch went away. */ +function currentPatchPaint(value: TrackScore): PatchPaint | undefined { + const model = patchModel(value, requestedTrack, patchView.state.fx); + if (typeof model === "string") return undefined; + clampState(patchView.state, model); + return patchPaint(model, patchView.state); +} + +/** + * One key on the patch view. True when it consumed the key; false sends + * it on (the prompt once a command is typed, overlays, ctrl keys). + */ +function patchInput(value: string): boolean { + if (!patchView.on || play?.on) return false; + if (tui.ui.overlay !== undefined || menu.open) return false; + if (prompt.value.length > 0) return false; + if (value === "/" || value === "\u0003") return false; + const model = patchModel(score, requestedTrack, patchView.state.fx); + if (typeof model === "string") { + tui.activity.pushCard(model, { tone: "info" }); + receipt(exitPatch()); + return true; + } + clampState(patchView.state, model); + const action = patchKey(patchView.state, model, value); + switch (action.type) { + case "run": + runTyped(action.commands); + return true; + case "node": { + const context = menuContext(); + const fx = patchView.state.fx; + menu.show(context, fx === undefined ? "patch" : `patch:fx:${fx}`); + if ( + !menu.enter(context, "patch:nodes") || + !menu.enter(context, nodeMenuId(action.nodeId)) + ) { + menu.close(); + tui.activity.pushCard(`patch set ${action.nodeId} =`, { + tone: "info", + }); + } + return true; + } + case "pick": + tui.openPicker({ + id: action.id, + title: action.title, + hint: HINTS.list, + items: action.items.map((item) => ({ + label: item.label, + value: item.value, + })), + filterable: true, + }); + return true; + case "prefill": + prompt.handle({ type: "text", text: action.text }); + return true; + case "note": + tui.activity.pushCard(action.message, { tone: "info" }); + return true; + case "transport": + void toggleTransport().catch((error: unknown) => transportFailed(error)); + return true; + case "keys": + showKeys(); + return true; + case "exit": + receipt(exitPatch()); + return true; + case "handled": + return true; + case "pass": + break; + } + // Unmapped printable keys are swallowed (as on TAPE); control keys + // (enter, ctrl-z, ctrl-k) keep their bindings. + return value.length === 1 && value >= " " && value !== "\u007f"; +} + function exitTape(): Receipt { if (!tape.on) return ok("tape is off"); tape.on = false; diff --git a/src/tui/knob-fields.ts b/src/tui/knob-fields.ts index f35b5786..d9860f9a 100644 --- a/src/tui/knob-fields.ts +++ b/src/tui/knob-fields.ts @@ -33,8 +33,11 @@ export function resolveKnobPath( let nodes: readonly MenuNode[] = rootNodes(context); let found: MenuNode | undefined; for (const segment of path.split("/")) { - found = nodes.find((node) => matches(node, segment)); - if (!found) { + const nth = /^#(\d+)$/.exec(segment); + found = nth + ? nodes[Number(nth[1]) - 1] + : nodes.find((node) => matches(node, segment)); + if (!found && !nth) { // Effects beyond the first few live under "more effects". const more = nodes.find( (node) => node.kind === "menu" && node.id === "more effects", diff --git a/src/tui/knob-map.test.ts b/src/tui/knob-map.test.ts index 2d376cb9..2989598a 100644 --- a/src/tui/knob-map.test.ts +++ b/src/tui/knob-map.test.ts @@ -71,7 +71,9 @@ describe("knob table", () => { test("every row is four slots, and orange is the level on every sound page", () => { for (const [page, row] of Object.entries(KNOB_MAPS)) { expect(row.length, page).toBe(4); - if (page.startsWith("sound:")) expect(row[3], page).toBe("mix/volume"); + // A patch's four knobs are its first four macros (patcher §7.2). + if (page.startsWith("sound:") && page !== "sound:patch") + expect(row[3], page).toBe("mix/volume"); } }); diff --git a/src/tui/knob-map.ts b/src/tui/knob-map.ts index 0e23a367..e3cf2f51 100644 --- a/src/tui/knob-map.ts +++ b/src/tui/knob-map.ts @@ -13,8 +13,10 @@ * Data, not code, so the patcher can map a patch's macros onto the same * four knobs by adding rows (`patch:`), and a test checks every path * resolves for every instrument family. A `*` at the end of a segment - * matches the first submenu whose id starts with it (`sample:*`). + * matches the first submenu whose id starts with it (`sample:*`); `#n` is + * the nth row of its level (a patch's macros have no fixed labels). */ +import { PATCH_INSTRUMENT } from "../../core/patch.ts"; import { isWavetableInstrument, type Track } from "../../core/score.ts"; import { isDrumInstrument } from "../../core/drums.ts"; import { @@ -144,6 +146,18 @@ export const KNOB_MAPS: Readonly> = Object.freeze({ * segment under the playhead, orange the focused track's volume. */ tape: ["playhead", "loop", "tempo", "volume"], + /** + * A patch track (patcher design §4): the patch's first four macros, in + * order (`#n` is the nth row of Sound › patch › knobs). A patch with + * fewer macros leaves the rest `·`. The patch view's header reads the + * same row. + */ + "sound:patch": [ + "sound/patch/knobs/#1", + "sound/patch/knobs/#2", + "sound/patch/knobs/#3", + "sound/patch/knobs/#4", + ], "fx:distort": [ undefined, "effects/distort/drive", @@ -154,6 +168,7 @@ export const KNOB_MAPS: Readonly> = Object.freeze({ /** The sound page's family for a track: which engine its Sound menu shows. */ export function soundFamily(track: Track): string { + if (track.instrument === PATCH_INSTRUMENT && track.patch) return "patch"; if (isDrumInstrument(track.instrument)) return "kit"; if (track.sampler) return "sampler"; if (isGranularInstrument(track.instrument)) return "granular"; diff --git a/src/tui/menu.ts b/src/tui/menu.ts index 600ad471..e238748f 100644 --- a/src/tui/menu.ts +++ b/src/tui/menu.ts @@ -8,7 +8,11 @@ * * Rendering reuses the TUI's picker overlay: `view()` returns a picker. */ -import { effectPatchMenuNode, patchMenuNode } from "./patch-menu.ts"; +import { + effectPatchMenuNode, + effectPatchMenuNodes, + patchMenuNode, +} from "./patch-menu.ts"; import { TOPIC_ALIASES } from "../lang/glossary.ts"; import { voiceEffectRows, @@ -2489,6 +2493,7 @@ function effectNodes(context: MenuContext): MenuNode[] { }, }; return [ + ...effectPatchMenuNodes(context, track), ...CORE_EFFECTS.map(node), rig, shoegaze, diff --git a/src/tui/patch-menu.ts b/src/tui/patch-menu.ts index 521da2c0..2fe62496 100644 --- a/src/tui/patch-menu.ts +++ b/src/tui/patch-menu.ts @@ -1,14 +1,237 @@ /** - * Ctrl-K rows for patches (patcher design §7.4): Sound › patch and - * Effects › add effect patch. Every row runs one typed `patch …` line, so - * the menu, the prompt, the agent and show-me share one grammar (f07 four - * doors). Lane 6 adds the patch view rows (`edit patch`) beside these. + * Ctrl-k rows for patches (patcher design §7.4): Sound › patch on every + * melodic or patch track, and one Effects › " patch" group per effect + * patch. Every row runs a typed command: `/patch` opens the patch view, a + * node's number rows run `patch set k=v`, the knobs run + * `patch knob `, and `--fx ` aims each at an effect + * patch. The patch view's enter on a node opens its group here in the + * fader drawer, so the drawer's keys, staging and keep/revert all apply. + * Below them sit the lane 5 rows (new, load, convert, add node, wire, …), + * each an entry that runs one typed `patch …` line, and Effects › add + * effect patch. */ - -import { isPatchRef } from "../../core/patch.ts"; +import type { NumberParam } from "../../core/params.ts"; +import { + isPatchRef, + macroAt, + macroPosition, + PATCH_INSTRUMENT, + patchSpecs, + resolvePatch, + type Macro, + type Patch, +} from "../../core/patch.ts"; import { BUILTIN_PATCH_NAMES } from "../../core/patches/index.ts"; +import type { Track, TrackScore } from "../../core/score.ts"; import { parsePatchCommand } from "../commands/patch.ts"; -import type { MenuContext, MenuNode } from "./menu.ts"; +import { num, specStep, type MenuContext, type MenuNode } from "./menu.ts"; + +/** ` --fx ` for an effect patch, empty for the instrument patch. */ +export function fxSuffix(fx: string | undefined): string { + return fx === undefined ? "" : ` --fx ${fx}`; +} + +/** The track's instrument patch (resolved through the library) or effect patch. */ +export function trackPatch( + score: TrackScore, + track: Track, + fx?: string, +): Patch | undefined { + if (fx !== undefined) return track.fxPatch?.find((p) => p.name === fx); + if (track.instrument !== PATCH_INSTRUMENT || !track.patch) return undefined; + return resolvePatch(track.patch, score.patches); +} + +/** A macro's value on this track: the ref's own setting, else its default. */ +export function macroValue(track: Track, macro: Macro, fx?: string): number { + const value = + fx === undefined && track.patch && isPatchRef(track.patch) + ? track.patch.macros?.[macro.id] + : undefined; + return value ?? macro.default; +} + +/** A macro nudged along its knob travel (exp macros move by ratio). */ +export function macroStep( + macro: Macro, +): (value: number, direction: 1 | -1) => number { + return (value, direction) => { + const position = Math.min( + 1, + Math.max(0, macroPosition(macro, value) + direction * 0.02), + ); + return Number(macroAt(macro, position).toPrecision(4)); + }; +} + +/** One row per macro (in knob order): `patch knob `. */ +export function macroNodes( + track: Track, + patch: Patch, + fx?: string, +): MenuNode[] { + return patch.macros.map((macro, index) => ({ + kind: "number", + label: macro.label ?? macro.id, + value: macroValue(track, macro, fx), + min: Math.min(macro.min, macro.max), + max: Math.max(macro.min, macro.max), + step: macroStep(macro), + format: (v) => num(v), + command: (v) => `patch knob ${macro.id} ${num(v)}${fxSuffix(fx)}`, + reset: `patch knob ${macro.id} ${num(macro.default)}${fxSuffix(fx)}`, + help: `${index < 4 ? `knob ${index + 1} · ` : ""}${macro.to.map((t) => t.port).join(", ") || "no targets"} · x resets`, + })); +} + +/** A node's static settings: numbers and choices, each `patch set`. */ +export function nodeParamNodes( + patch: Patch, + nodeId: string, + fx?: string, + library: Readonly> = {}, +): MenuNode[] { + const node = patch.nodes.find((n) => n.id === nodeId); + const spec = patchSpecs(patch, library).get(nodeId); + if (!node || !spec) return []; + const rows: MenuNode[] = []; + for (const [name, param] of Object.entries(spec.params)) { + const value = node.params?.[name]; + const set = (text: string) => + `patch set ${nodeId} ${name}=${text}${fxSuffix(fx)}`; + if (param.kind === "number") { + const p: NumberParam = param; + rows.push({ + kind: "number", + label: name, + value: typeof value === "number" ? value : p.default, + min: p.min, + max: p.max, + step: specStep(p), + format: (v) => (p.unit ? `${num(v)} ${p.unit}` : num(v)), + command: (v) => set(num(v)), + reset: set(num(p.default)), + help: `${p.doc} · default ${num(p.default)} · x resets`, + }); + } else if (param.kind === "enum") + rows.push({ + kind: "choice", + label: name, + value: typeof value === "string" ? value : param.default, + options: param.values, + command: (option) => set(option), + help: param.doc, + }); + else + rows.push({ + kind: "toggle", + label: name, + value: typeof value === "boolean" ? value : param.default, + command: (on) => set(on ? "on" : "off"), + help: param.doc, + }); + } + return rows; +} + +/** The menu id of a node's group (the patch view's enter opens it). */ +export function nodeMenuId(nodeId: string): string { + return `patch:node:${nodeId}`; +} + +function patchGroups( + context: MenuContext, + track: Track, + patch: Patch, + fx?: string, +): MenuNode[] { + return [ + { + kind: "action", + label: fx === undefined ? "Edit patch" : "Edit effect patch", + command: `/patch${fxSuffix(fx)}`, + help: "the patch view: nodes, ports and the cable matrix", + }, + { + kind: "menu", + id: "patch:knobs", + label: "knobs", + detail: patch.macros + .slice(0, 4) + .map((m) => m.label ?? m.id) + .join(" · "), + help: "the patch's macros; the first four are the four knobs", + build: () => macroNodes(track, patch, fx), + }, + { + kind: "menu", + id: "patch:nodes", + label: "nodes", + detail: `${patch.nodes.length} node${patch.nodes.length === 1 ? "" : "s"}`, + help: "every node's settings · patch set k=v", + build: () => + patch.nodes.map((node): MenuNode => ({ + kind: "menu", + id: nodeMenuId(node.id), + label: node.id, + detail: node.type, + help: `${node.type} settings`, + build: () => + nodeParamNodes(patch, node.id, fx, context.score.patches), + })), + }, + ]; +} + +/** Sound › patch on the focused track: the view, knobs, nodes, then edits. */ +export function patchMenuNode(context: MenuContext): MenuNode { + const track = context.score.tracks.find((t) => t.id === context.trackId); + const patch = track && trackPatch(context.score, track); + if (!track || !patch) + return { + kind: "menu", + id: "patch", + label: "patch", + detail: "off · /patch previews it as one", + help: "modular patches: nodes, cables and four knobs", + build: (inner) => [ + { + kind: "action", + label: "Edit patch", + command: "/patch", + help: "the patch view (a preview until the track is a patch)", + }, + ...patchRows(inner), + ], + }; + const ref = track.patch && isPatchRef(track.patch) ? track.patch.ref : ""; + return { + kind: "menu", + id: "patch", + label: "patch", + detail: `${ref || patch.name} · ${patch.nodes.length} nodes`, + help: "modular patches: nodes, cables and four knobs", + build: (inner) => [ + ...patchGroups(inner, track, patch), + ...patchRows(inner), + ], + }; +} + +/** Effects › " patch", one group per effect patch on the track. */ +export function effectPatchMenuNodes( + context: MenuContext, + track: Track | undefined, +): MenuNode[] { + return (track?.fxPatch ?? []).map((patch) => ({ + kind: "menu", + id: `patch:fx:${patch.name}`, + label: `${patch.name} patch`, + detail: `effect patch · ${patch.nodes.length} nodes`, + help: "an effect patch at the chain's patch stage", + build: (inner) => patchGroups(inner, track!, patch, patch.name), + })); +} /** `patch ` when it parses, else undefined (the entry waits). */ function patchLine(verb: string, text: string): string | undefined { @@ -145,21 +368,6 @@ function patchRows(context: MenuContext): MenuNode[] { return rows; } -/** Sound › patch. */ -export function patchMenuNode(context: MenuContext): MenuNode { - const track = context.score.tracks.find((t) => t.id === context.trackId); - const patch = track?.patch; - const detail = !patch ? "off" : isPatchRef(patch) ? patch.ref : patch.name; - return { - kind: "menu", - id: "patch", - label: "patch", - detail, - help: "a modular patch: nodes, cables and knobs", - build: patchRows, - }; -} - /** Effects › add effect patch. */ export function effectPatchMenuNode(): MenuNode { return { diff --git a/src/tui/patch-view.test.ts b/src/tui/patch-view.test.ts new file mode 100644 index 00000000..c97e6577 --- /dev/null +++ b/src/tui/patch-view.test.ts @@ -0,0 +1,390 @@ +/** + * The patch view (patcher design §7): the model of a track's patch, each + * key's typed command, and the painter at the small and large sizes, under + * mono and ASCII. + */ +import { describe, expect, test } from "bun:test"; +import { builtinPatch } from "../../core/patches/index.ts"; +import { createScore, type TrackScore } from "../../core/score.ts"; +import { CellBuffer } from "../../tui/screen.ts"; +import { PATCH_NODES_MAX, paintPatch } from "../../tui/patch.ts"; +import { getTheme } from "../../tui/theme.ts"; +import { + legalCable, + matrixAxes, + patchKey, + patchModel, + patchPaint, + patchViewState, + wireTargets, + type PatchModel, + type PatchViewState, +} from "./patch-view.ts"; + +function song(track: Record): TrackScore { + return createScore({ + tempoBpm: 120, + bars: 2, + tracks: [{ id: "t", name: "t", ...track }], + notes: [], + } as Parameters[0]); +} + +function acid(): TrackScore { + return song({ instrument: "patch", patch: builtinPatch("acid-bass") }); +} + +function model(score: TrackScore, fx?: string): PatchModel { + const value = patchModel(score, "t", fx); + if (typeof value === "string") throw new Error(value); + return value; +} + +function keys( + m: PatchModel, + state: PatchViewState, + ...values: string[] +): ReturnType { + let last: ReturnType = { type: "handled" }; + for (const value of values) last = patchKey(state, m, value); + return last; +} + +describe("patchModel", () => { + test("lists boundary and patch nodes in signal order, out last", () => { + const m = model(acid()); + const ids = m.nodes.map((n) => n.id); + expect(ids[0]).toBe("voice"); + expect(ids.at(-1)).toBe("out"); + for (const node of m.patch.nodes) expect(ids).toContain(node.id); + // A cable's source comes before its destination (no feedback here). + for (const cable of m.patch.cables) { + const from = ids.indexOf(cable.from.split(".")[0]!); + const to = ids.indexOf(cable.to.split(".")[0]!); + expect(from).toBeLessThan(to); + } + }); + + test("a non-patch track previews as a patch, read-only", () => { + const m = model(song({ instrument: "saw" })); + expect(m.preview).toBe(true); + const state = patchViewState(); + const action = patchKey(state, m, "a"); + expect(action.type).toBe("note"); + if (action.type === "note") + expect(action.message).toContain("patch convert"); + }); + + test("a library reference is read-only and says how to detach", () => { + const patch = builtinPatch("acid-bass")!; + const score = createScore({ + tempoBpm: 120, + bars: 2, + patches: { "acid-bass": patch }, + tracks: [ + { + id: "t", + name: "t", + instrument: "patch", + patch: { kind: "patch", ref: "acid-bass" }, + }, + ], + notes: [], + } as Parameters[0]); + const m = model(score); + expect(m.ref).toBe("acid-bass"); + const state = patchViewState(); + state.node = m.nodes.findIndex((n) => !n.boundary); + const action = patchKey(state, m, "x"); + expect(action.type).toBe("note"); + if (action.type === "note") + expect(action.message).toContain("patch detach"); + expect(patchPaint(m, patchViewState()).tag).toContain("library acid-bass"); + }); + + test("a missing effect patch is a message, not a model", () => { + expect(typeof patchModel(acid(), "t", "nope")).toBe("string"); + expect(typeof patchModel(acid(), "nobody")).toBe("string"); + }); + + test("an effect patch lists in.audio and no voice", () => { + const score = song({ + instrument: "saw", + fxPatch: [builtinPatch("wide-crush")], + }); + const m = model(score, "wide-crush"); + const ids = m.nodes.map((n) => n.id); + expect(ids).toContain("in"); + expect(ids).not.toContain("voice"); + expect(m.sources.some((s) => s.ref === "in.audio")).toBe(true); + expect(m.sources.some((s) => s.ref === "in.notes")).toBe(false); + // Every command carries --fx. + const state = patchViewState("wide-crush"); + const action = keys(m, state, "a"); + expect(action.type).toBe("pick"); + if (action.type === "pick") { + expect( + action.items.every((i) => i.value.endsWith("--fx wide-crush")), + ).toBe(true); + // No engines in an effect patch. + expect(action.items.some((i) => i.value.includes("add engine."))).toBe( + false, + ); + } + }); + + test("surfaces the compiler's dropped cables in cells and warnings", () => { + const base = builtinPatch("acid-bass")!; + // A feedback loop across an engine is cut by the compiler. + const engine = base.nodes.find((n) => + ["osc", "fm", "wavetable", "engine.synth"].includes(n.type), + ); + expect(engine).toBeDefined(); + const filter = base.nodes.find( + (n) => n.type === "svf" || n.type === "ladder", + ); + const patch = { + ...base, + cables: [ + ...base.cables, + { id: "loop1", from: `${filter!.id}.out`, to: `${engine!.id}.fm` }, + ], + }; + const m = model(song({ instrument: "patch", patch })); + if (m.warnings.some((w) => w.startsWith("cable loop1:"))) { + expect(m.dropped.has("loop1")).toBe(true); + const state = patchViewState(); + state.full = true; + const paint = patchPaint(m, state); + expect(paint.warnings.length).toBeGreaterThan(0); + expect(paint.cells.flat().some((c) => c.dropped)).toBe(true); + } else { + // The compiler delays it instead: a warning, never a dropped cell. + expect(m.warnings.some((w) => w.includes("loop1"))).toBe(true); + expect(m.dropped.has("loop1")).toBe(false); + } + }); +}); + +describe("patchKey", () => { + test("enter on a node opens its settings; on a boundary explains it", () => { + const m = model(acid()); + const state = patchViewState(); + expect(patchKey(state, m, "\r").type).toBe("note"); + state.node = m.nodes.findIndex((n) => !n.boundary); + const action = patchKey(state, m, "\r"); + expect(action).toEqual({ type: "node", nodeId: m.nodes[state.node]!.id }); + }); + + test("x removes a node with patch rm; never a boundary", () => { + const m = model(acid()); + const state = patchViewState(); + state.node = m.nodes.findIndex((n) => !n.boundary); + expect(patchKey(state, m, "x")).toEqual({ + type: "run", + commands: [`patch rm ${m.nodes[state.node]!.id}`], + }); + state.node = m.nodes.length - 1; + expect(patchKey(state, m, "x").type).toBe("note"); + }); + + test("w lists only legal destinations, each a patch wire", () => { + const m = model(acid()); + const from = "voice.pitch"; + const targets = wireTargets(m, from); + expect(targets.length).toBeGreaterThan(0); + for (const to of targets) expect(legalCable(m, from, to)).toBe(true); + // Audio ports never take a notes cable; notes go only to notes. + expect(legalCable(m, "voice.pitch", "out.audio")).toBe(true); + const state = patchViewState(); + const action = patchKey(state, m, "w"); + expect(action.type).toBe("pick"); + if (action.type === "pick") + for (const item of action.items) + expect(item.value).toMatch(/^patch wire voice\.\S+ \S+\.\S+$/); + }); + + test("matrix: enter wires and unwires, [ ] and digits set the amount", () => { + const m = model(acid()); + const state = patchViewState(); + state.full = true; + state.pane = "matrix"; + const { rows, cols } = matrixAxes(m, state); + const cable = m.patch.cables.find( + (c) => cols.find((col) => col.ref === c.to)?.kind === "control", + )!; + state.row = rows.findIndex((r) => r.ref === cable.from); + state.col = cols.findIndex((c) => c.ref === cable.to); + expect(patchKey(state, m, "\r")).toEqual({ + type: "run", + commands: [`patch unwire ${cable.from} ${cable.to}`], + }); + const amount = cable.amount ?? 1; + const down = patchKey(state, m, "["); + expect(down.type).toBe("run"); + if (down.type === "run") + expect(down.commands[0]).toBe( + `patch wire ${cable.from} ${cable.to} ${Math.round((amount - 0.05) * 100) / 100}`, + ); + expect(patchKey(state, m, "5")).toEqual({ + type: "run", + commands: [`patch wire ${cable.from} ${cable.to} 0.5`], + }); + expect(patchKey(state, m, "0")).toEqual({ + type: "run", + commands: [`patch wire ${cable.from} ${cable.to} 1`], + }); + }); + + test("tab cycles panes; f toggles the full matrix; esc exits", () => { + const m = model(acid()); + const state = patchViewState(); + keys(m, state, "\t"); + expect(state.pane).toBe("ports"); + keys(m, state, "\t"); + expect(state.pane).toBe("matrix"); + keys(m, state, "\t"); + expect(state.pane).toBe("knobs"); + keys(m, state, "\t"); + expect(state.pane).toBe("nodes"); + keys(m, state, "f"); + expect(state.full).toBe(true); + expect(patchKey(state, m, "\u001b").type).toBe("exit"); + expect(patchKey(state, m, "s")).toEqual({ + type: "prefill", + text: "patch save acid-bass", + }); + }); + + test("knobs pane turns the first macro with patch knob", () => { + const m = model(acid()); + const state = patchViewState(); + state.pane = "knobs"; + const action = patchKey(state, m, "\u001b[C"); + expect(action.type).toBe("run"); + if (action.type === "run") + expect(action.commands[0]).toMatch( + new RegExp(`^patch knob ${m.patch.macros[0]!.id} \\S+$`), + ); + }); + + test("m on a mappable param offers existing macros and a new one", () => { + const m = model(acid()); + const state = patchViewState(); + state.node = m.nodes.findIndex( + (n) => n.type === "svf" || n.type === "ladder", + ); + state.pane = "ports"; + const action = (() => { + for (let i = 0; i < 20; i++) { + const a = patchKey(state, m, "m"); + if (a.type === "pick") return a; + patchKey(state, m, "\u001b[B"); + } + return undefined; + })(); + expect(action?.type).toBe("pick"); + if (action?.type === "pick") { + expect( + action.items.every((i) => i.value.startsWith("patch macro ")), + ).toBe(true); + expect(action.items.at(-1)!.label).toContain("new knob"); + } + }); + + test("g toggles a node that can run either way", () => { + const m = model(acid()); + const state = patchViewState(); + const either = m.nodes.findIndex((n) => n.either); + if (either < 0) return; + state.node = either; + const action = patchKey(state, m, "g"); + expect(action.type).toBe("run"); + if (action.type === "run") + expect(action.commands[0]).toMatch(/^patch rate \S+ (global|voice)$/); + }); +}); + +function paint( + width: number, + height: number, + m: PatchModel, + state: PatchViewState, + options: { theme?: "default" | "mono"; unicode?: boolean } = {}, +): string[] { + const buffer = new CellBuffer(width, height); + paintPatch(buffer, { x: 0, y: 0, width, height }, patchPaint(m, state), { + theme: getTheme(options.theme ?? "default"), + unicode: options.unicode ?? true, + }); + return buffer.lines(); +} + +describe("paintPatch", () => { + test("80x18: header, nodes, ports, matrix, knobs and hint", () => { + const m = model(acid()); + const lines = paint(80, 18, m, patchViewState()); + const text = lines.join("\n"); + expect(lines[0]).toContain("patch t ▸ acid-bass (instrument"); + expect(text).toContain(" NODES "); + expect(text).toContain("CABLES"); + expect(text).toContain("▸voice"); + expect(lines.at(-1)).toContain("tab pane"); + expect(lines.at(-2)).toContain( + m.patch.macros[0]!.label ?? m.patch.macros[0]!.id, + ); + for (const line of lines) expect(line.length).toBeLessThanOrEqual(80); + }); + + test("a short body shows the matrix alone while it has focus", () => { + const m = model(acid()); + const state = patchViewState(); + state.pane = "matrix"; + state.full = true; + const text = paint(60, 9, m, state).join("\n"); + expect(text).not.toContain(" NODES "); + expect(text).toContain("CABLES"); + state.pane = "nodes"; + const lists = paint(60, 9, m, state).join("\n"); + expect(lists).toContain(" NODES "); + }); + + test("ASCII keeps every cue", () => { + const m = model(acid()); + const state = patchViewState(); + state.full = true; + const text = paint(100, 30, m, state, { + unicode: false, + theme: "mono", + }).join("\n"); + expect(text).not.toMatch(/[▸◆●▪♪Σ✕│─⚠←→█]/u); + expect(text).toContain(">voice"); + expect(text).toContain("v voice g global"); + }); + + test("a large terminal caps the lists and shows the whole matrix", () => { + const m = model(acid()); + const state = patchViewState(); + state.full = true; + const lines = paint(300, 80, m, state); + const text = lines.join("\n"); + expect(text).toContain("drive.mix"); + // Wide enough for every label whole, every column shows whole. + const wide = paint(420, 40, m, state).join("\n"); + for (const ref of m.targets.map((c) => c.ref)) expect(wide).toContain(ref); + // The node list stops at PATCH_NODES_MAX columns. + const bar = lines[2]!.indexOf("│"); + expect(bar).toBeLessThanOrEqual(PATCH_NODES_MAX); + }); + + test("tiny rects draw nothing and never throw", () => { + const m = model(acid()); + for (const [w, h] of [ + [1, 1], + [19, 3], + [20, 4], + [60, 5], + ] as const) + expect(() => paint(w, h, m, patchViewState())).not.toThrow(); + }); +}); diff --git a/src/tui/patch-view.ts b/src/tui/patch-view.ts new file mode 100644 index 00000000..f6b4463b --- /dev/null +++ b/src/tui/patch-view.ts @@ -0,0 +1,1012 @@ +/** + * The patch view (patcher design §7): a node list, the selected node's + * ports, and a cable matrix (rows are outputs, columns are inputs), with + * the patch's first four macros on the four knobs. + * + * ↑↓ move in the focused pane tab ⇧tab next pane + * ←→ nodes ⇄ ports; across the matrix's columns + * enter node: its settings in the drawer · cell: wire / unwire + * port: jump to the node on its other end + * [ ] { } cell amount −/+ 5 % (⇧ 25 %) · 1-9 0: 10-90 %, 100 % + * a w x m g add node · wire to… · remove · map to a knob · voice/global + * f s space full matrix · save · play esc back home + * + * Pure: `patchModel` reads the score, `patchKey` turns a key into what to + * run, and every edit is a typed `patch …` command (`--fx ` on an + * effect patch), so the receipt teaches the words. The painter is + * tui/patch.ts. + */ +import { + cableKindError, + inferRates, + isPatchRef, + macroAt, + macroPosition, + patchFromTrack, + patchSpecs, + PATCH_LIMITS, + splitPort, + type Cable, + type Patch, + type PortKind, + type Rate, +} from "../../core/patch.ts"; +import { + NODE_SPECS, + NODE_TYPES, + type NodeSpec, +} from "../../core/patch-nodes.ts"; +import type { Track, TrackScore } from "../../core/score.ts"; +import { compilePatch } from "../audio/patch/compile.ts"; +import { + KEY_COARSE_LEFT, + KEY_COARSE_RIGHT, + KEY_DOWN, + KEY_ENTER, + KEY_LEFT, + KEY_RIGHT, + KEY_UP, +} from "../../tui/grammar.ts"; +import { + knobKey, + type KnobIndex, + type KnobSlot, + type KnobSlots, + type KnobState, +} from "../../tui/knobs.ts"; +import type { PaintCell, PatchPaint } from "../../tui/patch.ts"; +import { num } from "./menu.ts"; +import { fxSuffix, macroValue, trackPatch } from "./patch-menu.ts"; + +export type PatchPane = "nodes" | "ports" | "matrix" | "knobs"; + +/** One pane's view state (each terminal pane keeps its own). */ +export type PatchViewState = { + /** The effect patch shown, by name; absent: the instrument patch. */ + fx?: string | undefined; + pane: PatchPane; + /** Index into the model's node list. */ + node: number; + /** Index into the selected node's port rows. */ + port: number; + row: number; + col: number; + /** The whole matrix, not just the selected node's neighbourhood. */ + full: boolean; + knobs: KnobState; +}; + +export function patchViewState(fx?: string): PatchViewState { + return { + ...(fx === undefined ? {} : { fx }), + pane: "nodes", + node: 0, + port: 0, + row: 0, + col: 0, + full: false, + knobs: { selected: 0 }, + }; +} + +export type ModelNode = Readonly<{ + id: string; + type: string; + rate: Rate; + /** `in`, `out`, `voice` or `song`: listed, never removed. */ + boundary: boolean; + /** A short summary: the first choice param's value (`saw`, `lp`). */ + detail: string; + /** The node can run per voice or globally (`g` toggles it). */ + either: boolean; + /** Forced global by the patch. */ + forced: boolean; +}>; + +export type PortRow = Readonly< + | { kind: "param"; name: string; text: string; mappable: boolean } + | { + kind: "in"; + name: string; + port: PortKind; + /** Sources wired into it (`voice.pitch`). */ + links: readonly string[]; + mappable: boolean; + } + | { kind: "out"; name: string; port: PortKind; links: readonly string[] } +>; + +export type MatrixRow = Readonly<{ + /** `vcf.out`, or `macro:` for a macro's targets. */ + ref: string; + label: string; + kind: PortKind | "macro"; + macro?: number; +}>; + +export type MatrixCol = Readonly<{ ref: string; kind: PortKind }>; + +export type Cell = Readonly<{ + cable?: Cable | undefined; + /** A macro row: the macro maps onto this port. */ + mapped?: boolean; + /** A cable here would be accepted (kinds, role, no duplicate). */ + legal: boolean; + /** The compiler dropped this cable (its warning names it). */ + dropped: boolean; + /** A voice cable into a global node: summed over voices (Σ). */ + sum: boolean; +}>; + +export type PatchModel = Readonly<{ + track: Track; + patch: Patch; + fx?: string | undefined; + /** The track is not a patch: this is `patchFromTrack`, read-only. */ + preview: boolean; + /** The library patch the track plays (a reference), when it is one. */ + ref?: string | undefined; + nodes: readonly ModelNode[]; + specs: ReadonlyMap; + rates: ReadonlyMap; + /** Every output and every input, in node order. */ + sources: readonly MatrixRow[]; + targets: readonly MatrixCol[]; + /** The compiler's warnings: dropped and delayed cables, voice caps. */ + warnings: readonly string[]; + /** Cable ids the compiler dropped. */ + dropped: ReadonlySet; + voiceSums: ReadonlySet; + voices: number; +}>; + +const BOUNDARY = ["in", "voice", "song", "out"]; + +/** Topological order (sources first), ties by patch order; cycles appended. */ +function signalOrder(patch: Patch, ids: readonly string[]): string[] { + const incoming = new Map>(); + for (const id of ids) incoming.set(id, new Set()); + for (const cable of patch.cables) { + const from = splitPort(cable.from)[0]; + const to = splitPort(cable.to)[0]; + if (from !== to && incoming.has(to) && incoming.has(from)) + incoming.get(to)!.add(from); + } + const out: string[] = []; + const done = new Set(); + while (out.length < ids.length) { + const next = + ids.find( + (id) => + !done.has(id) && [...incoming.get(id)!].every((d) => done.has(d)), + ) ?? ids.find((id) => !done.has(id))!; + done.add(next); + out.push(next); + } + return out; +} + +function nodeDetail(patch: Patch, id: string, spec: NodeSpec): string { + const node = patch.nodes.find((n) => n.id === id); + for (const [name, param] of Object.entries(spec.params)) + if (param.kind === "enum") { + const value = node?.params?.[name]; + return typeof value === "string" ? value : param.default; + } + return ""; +} + +/** The ids the compiler names in a "cable : …" warning it drops. */ +function droppedCables(warnings: readonly string[]): Set { + const out = new Set(); + for (const warning of warnings) { + const id = /^cable (\S+): /.exec(warning)?.[1]; + if (id) out.add(id); + } + return out; +} + +/** The view's model of the focused track's patch, or why there is none. */ +export function patchModel( + score: TrackScore, + trackId: string, + fx?: string, +): PatchModel | string { + const track = score.tracks.find((t) => t.id === trackId); + if (!track) return "no track focused"; + const library = score.patches ?? {}; + let patch = trackPatch(score, track, fx); + let preview = false; + if (!patch) { + if (fx !== undefined) return `${track.id} has no effect patch ${fx}`; + if (track.patch && isPatchRef(track.patch)) + return `${track.id} plays patch ${track.patch.ref}, which the song does not hold`; + patch = patchFromTrack(track); + preview = true; + } + const specs = patchSpecs(patch, library); + const { rates, voiceSums } = inferRates(patch, library); + const used = new Set(); + for (const cable of patch.cables) { + used.add(splitPort(cable.from)[0]); + used.add(splitPort(cable.to)[0]); + } + const boundary = BOUNDARY.filter( + (id) => + used.has(id) || + id === "out" || + (id === "in" && patch.role === "effect") || + (id === "voice" && patch.role === "instrument"), + ); + const ids = [ + ...boundary.filter((id) => id !== "out"), + ...patch.nodes.map((n) => n.id), + ]; + const ordered = [...signalOrder(patch, ids), "out"]; + const nodes: ModelNode[] = ordered.map((id) => { + const spec = specs.get(id)!; + const node = patch.nodes.find((n) => n.id === id); + return { + id, + type: node?.type ?? id, + rate: rates.get(id) ?? "global", + boundary: !node, + detail: nodeDetail(patch, id, spec), + either: spec.rate === "any" && Boolean(node), + forced: node?.rate === "global", + }; + }); + const outs = (id: string, spec: NodeSpec) => + spec.outputs.filter((port) => { + if (id !== "in") return true; + return patch.role === "instrument" + ? port.name === "notes" + : port.name !== "notes" && + (port.name !== "side" || Boolean(patch.side)); + }); + const sources: MatrixRow[] = nodes.flatMap((node) => + outs(node.id, specs.get(node.id)!).map((port) => ({ + ref: `${node.id}.${port.name}`, + label: `${node.id}.${port.name}`, + kind: port.kind, + })), + ); + patch.macros.forEach((macro, index) => + sources.push({ + ref: `macro:${macro.id}`, + label: macro.label ?? macro.id, + kind: "macro", + macro: index, + }), + ); + const targets: MatrixCol[] = nodes.flatMap((node) => + specs.get(node.id)!.inputs.map((port) => ({ + ref: `${node.id}.${port.name}`, + kind: port.kind, + })), + ); + let warnings: readonly string[] = []; + let voices = patch.voices ?? PATCH_LIMITS.defaultVoices; + try { + const program = compilePatch(patch, library); + warnings = program.warnings; + voices = program.voices; + } catch (error) { + warnings = [error instanceof Error ? error.message : String(error)]; + } + const dropped = new Set( + [...droppedCables(warnings)].filter( + (id) => + !warnings.some((w) => w.startsWith(`feedback: cable ${id} is delayed`)), + ), + ); + return { + track, + patch, + ...(fx === undefined ? {} : { fx }), + preview, + ...(track.patch && isPatchRef(track.patch) && fx === undefined + ? { ref: track.patch.ref } + : {}), + nodes, + specs, + rates, + sources, + targets, + warnings, + dropped, + voiceSums: new Set(voiceSums), + voices, + }; +} + +function portKind( + model: PatchModel, + ref: string, + dir: "in" | "out", +): PortKind | undefined { + const [id, name] = splitPort(ref); + const spec = model.specs.get(id); + const ports = dir === "in" ? spec?.inputs : spec?.outputs; + return ports?.find((port) => port.name === name)?.kind; +} + +/** + * Whether a cable `from → to` would validate (core/patch.ts rules): the + * kinds agree, the role has that input, and the pair is not wired yet. + */ +export function legalCable( + model: PatchModel, + from: string, + to: string, +): boolean { + const out = portKind(model, from, "out"); + const into = portKind(model, to, "in"); + if (!out || !into) return false; + if (cableKindError(out, into, to)) return false; + if (model.patch.cables.length >= PATCH_LIMITS.maxCables) return false; + if (model.patch.cables.some((c) => c.from === from && c.to === to)) + return false; + const [source, port] = splitPort(from); + if (model.patch.role === "effect" && (source === "voice" || port === "notes")) + return false; + if (model.patch.role === "instrument" && source === "in" && port !== "notes") + return false; + return true; +} + +/** The legal destinations of an output: the `w` list (never an illegal port). */ +export function wireTargets(model: PatchModel, from: string): string[] { + return model.targets + .map((col) => col.ref) + .filter((to) => legalCable(model, from, to)); +} + +export function cableAt( + model: PatchModel, + from: string, + to: string, +): Cable | undefined { + return model.patch.cables.find((c) => c.from === from && c.to === to); +} + +export function cell(model: PatchModel, row: MatrixRow, col: MatrixCol): Cell { + if (row.kind === "macro") { + const macro = model.patch.macros[row.macro!]!; + return { + mapped: macro.to.some((t) => t.port === col.ref), + legal: false, + dropped: false, + sum: false, + }; + } + const cable = cableAt(model, row.ref, col.ref); + return { + cable, + legal: cable ? true : legalCable(model, row.ref, col.ref), + dropped: cable ? model.dropped.has(cable.id) : false, + sum: cable ? model.voiceSums.has(cable.id) : false, + }; +} + +/** The matrix shown: the selected node's neighbourhood, or all of it. */ +export function matrixAxes( + model: PatchModel, + state: PatchViewState, +): Readonly<{ rows: readonly MatrixRow[]; cols: readonly MatrixCol[] }> { + if (state.full) return { rows: model.sources, cols: model.targets }; + const node = model.nodes[clampIndex(state.node, model.nodes.length)]; + if (!node) return { rows: [], cols: [] }; + const id = node.id; + const touches = (ref: string) => splitPort(ref)[0] === id; + const rowRefs = new Set(); + const colRefs = new Set(); + for (const cable of model.patch.cables) { + if (touches(cable.to)) rowRefs.add(cable.from); + if (touches(cable.from)) colRefs.add(cable.to); + } + const rows = model.sources.filter( + (row) => + touches(row.ref) || + rowRefs.has(row.ref) || + (row.kind === "macro" && + model.patch.macros[row.macro!]!.to.some((t) => touches(t.port))), + ); + const cols = model.targets.filter( + (col) => touches(col.ref) || colRefs.has(col.ref), + ); + return { rows, cols }; +} + +function clampIndex(index: number, length: number): number { + return Math.max(0, Math.min(length - 1, index)); +} + +/** The selected node's port rows: number/choice params, inputs, outputs. */ +export function portRows(model: PatchModel, nodeId: string): PortRow[] { + const spec = model.specs.get(nodeId); + if (!spec) return []; + const node = model.patch.nodes.find((n) => n.id === nodeId); + const inputs = new Set(spec.inputs.map((p) => p.name)); + const rows: PortRow[] = []; + for (const [name, param] of Object.entries(spec.params)) { + if (inputs.has(name)) continue; + const value = node?.params?.[name]; + const text = + param.kind === "number" + ? num(typeof value === "number" ? value : param.default) + : param.kind === "enum" + ? typeof value === "string" + ? value + : param.default + : (typeof value === "boolean" ? value : param.default) + ? "on" + : "off"; + rows.push({ + kind: "param", + name, + text, + mappable: param.kind === "number" && Boolean(node), + }); + } + for (const port of spec.inputs) + rows.push({ + kind: "in", + name: port.name, + port: port.kind, + links: model.patch.cables + .filter((c) => c.to === `${nodeId}.${port.name}`) + .map((c) => c.from), + mappable: port.kind === "control" && Boolean(port.spec) && Boolean(node), + }); + for (const port of spec.outputs) + if (model.sources.some((s) => s.ref === `${nodeId}.${port.name}`)) + rows.push({ + kind: "out", + name: port.name, + port: port.kind, + links: model.patch.cables + .filter((c) => c.from === `${nodeId}.${port.name}`) + .map((c) => c.to), + }); + return rows; +} + +// ── the four knobs: the patch's first four macros ───────────────────── + +/** + * The macros on the four knobs (src/tui/knob-map.ts `sound:patch`): each + * turn is `patch knob `, 2 % of the knob's travel (⇧ 10 %), + * so an exp macro turns by ratio as its sound does. + */ +export function patchKnobs(model: PatchModel): KnobSlots { + const slots = [0, 1, 2, 3].map((index): KnobSlot | undefined => { + const macro = model.patch.macros[index]; + if (!macro) return undefined; + const value = macroValue(model.track, macro, model.fx); + const position = macroPosition(macro, value); + const suffix = fxSuffix(model.fx); + return { + label: macro.label ?? macro.id, + text: num(value), + position, + turn: (direction, coarse) => { + const next = Math.min( + 1, + Math.max(0, position + direction * (coarse ? 0.1 : 0.02)), + ); + const target = Number(macroAt(macro, next).toPrecision(4)); + return target === value + ? undefined + : `patch knob ${macro.id} ${num(target)}${suffix}`; + }, + reset: `patch knob ${macro.id} ${num(macro.default)}${suffix}`, + }; + }); + return slots as unknown as KnobSlots; +} + +// ── keys ────────────────────────────────────────────────────────────── + +export type PatchAction = + /** Run these typed commands in order (each echoes, as if typed). */ + | Readonly<{ type: "run"; commands: readonly string[] }> + /** Open the node's settings (Sound › patch › nodes › ) in the drawer. */ + | Readonly<{ type: "node"; nodeId: string }> + /** A pick list: each item runs its command; `audition` hears each row. */ + | Readonly<{ + type: "pick"; + id: "patch-add" | "patch-wire" | "patch-map"; + title: string; + items: readonly Readonly<{ label: string; value: string }>[]; + }> + /** Put a command in the prompt to finish and confirm (`patch save …`). */ + | Readonly<{ type: "prefill"; text: string }> + | Readonly<{ type: "note"; message: string }> + | Readonly<{ type: "handled" }> + | Readonly<{ type: "transport" }> + | Readonly<{ type: "keys" }> + | Readonly<{ type: "exit" }> + /** Not a patch-view key: the prompt, ctrl keys. */ + | Readonly<{ type: "pass" }>; + +/** The hint row: the main gestures; the rest are in `?` (KEYS.patch). */ +export const PATCH_HINT = + "tab pane · enter edit · a add · w wire · x remove · m knob · ? keys"; + +const PANES: readonly PatchPane[] = ["nodes", "ports", "matrix", "knobs"]; + +function cycle(state: PatchViewState, model: PatchModel, by: 1 | -1): void { + const panes = PANES.filter( + (pane) => pane !== "knobs" || model.patch.macros.length > 0, + ); + const at = Math.max(0, panes.indexOf(state.pane)); + state.pane = panes[(at + by + panes.length) % panes.length]!; +} + +const READ_ONLY = (model: PatchModel): string => + model.ref !== undefined + ? `${model.track.id} plays library patch ${model.ref} · its knobs turn here; patch detach makes it editable` + : `${model.track.id} is not a patch yet · this is its ${model.patch.nodes[0]?.type ?? "engine"} as one · patch convert makes it one`; + +/** Clamp every index to the model (the score may have changed). */ +export function clampState(state: PatchViewState, model: PatchModel): void { + state.node = clampIndex(state.node, model.nodes.length); + const node = model.nodes[state.node]; + const ports = node ? portRows(model, node.id) : []; + state.port = clampIndex(state.port, Math.max(1, ports.length)); + const { rows, cols } = matrixAxes(model, state); + state.row = clampIndex(state.row, Math.max(1, rows.length)); + state.col = clampIndex(state.col, Math.max(1, cols.length)); + if (state.pane === "knobs" && model.patch.macros.length === 0) + state.pane = "nodes"; +} + +function amountOf(cable: Cable): number { + return cable.amount ?? 1; +} + +function amountCommand( + model: PatchModel, + from: string, + to: string, + amount: number, +): string { + const value = Math.round(Math.max(-1, Math.min(1, amount)) * 100) / 100; + return `patch wire ${from} ${to} ${num(value)}${fxSuffix(model.fx)}`; +} + +function newMacroId(model: PatchModel, name: string): string { + const base = + name.replace(/[^a-z0-9-]+/g, "-").replace(/^[^a-z]+/, "") || "knob"; + const taken = new Set(model.patch.macros.map((m) => m.id)); + if (!taken.has(base)) return base; + for (let n = 2; ; n += 1) if (!taken.has(`${base}${n}`)) return `${base}${n}`; +} + +/** The `m` list for a port: each existing macro, then a new one. */ +export function mapItems( + model: PatchModel, + ref: string, +): { label: string; value: string }[] { + const suffix = fxSuffix(model.fx); + const [, name] = splitPort(ref); + const items = model.patch.macros.map((macro, index) => ({ + label: `${index < 4 ? `knob ${index + 1}` : " "} ${macro.label ?? macro.id} (${macro.to.map((t) => t.port).join(", ")})`, + value: `patch macro ${macro.id} ${[...macro.to.map((t) => t.port), ref].join(" ")}${suffix}`, + })); + if (model.patch.macros.length < PATCH_LIMITS.maxMacros) { + const id = newMacroId(model, name); + items.push({ + label: `new knob ${id} → ${ref}`, + value: `patch macro ${id} ${ref}${suffix}`, + }); + } + return items.filter((item) => !item.label.includes(`(${ref})`)); +} + +/** The `a` list: every node type the patch's role may hold. */ +export function addItems( + model: PatchModel, +): { label: string; value: string }[] { + return NODE_TYPES.filter( + (type) => + model.patch.role === "instrument" || + NODE_SPECS[type]!.family !== "engine", + ).map((type) => { + const spec = NODE_SPECS[type]!; + return { + label: `${type.padEnd(16)} ${spec.family.padEnd(8)} ${spec.doc}`, + value: `patch add ${type}${fxSuffix(model.fx)}`, + }; + }); +} + +/** One key on the patch view. Mutates `state` (focus, scroll). */ +export function patchKey( + state: PatchViewState, + model: PatchModel, + value: string, +): PatchAction { + clampState(state, model); + const suffix = fxSuffix(model.fx); + const node = model.nodes[state.node]; + const ports = node ? portRows(model, node.id) : []; + const port = ports[state.port]; + const { rows, cols } = matrixAxes(model, state); + const row = rows[state.row]; + const col = cols[state.col]; + const editable = !model.preview && model.ref === undefined; + const edit = (action: PatchAction): PatchAction => + editable ? action : { type: "note", message: READ_ONLY(model) }; + + switch (value) { + case "\u001b": + return { type: "exit" }; + case " ": + return { type: "transport" }; + case "?": + return { type: "keys" }; + case "\t": + cycle(state, model, 1); + return { type: "handled" }; + case "\u001b[Z": + cycle(state, model, -1); + return { type: "handled" }; + case "f": + state.full = !state.full; + state.row = 0; + state.col = 0; + return { type: "handled" }; + case "s": + return edit({ type: "prefill", text: `patch save ${model.patch.name}` }); + case "a": + return edit({ + type: "pick", + id: "patch-add", + title: `add a node to ${model.patch.name}`, + items: addItems(model), + }); + } + + if (state.pane === "knobs") { + const knob = knobKey(state.knobs, patchKnobs(model), value); + if (knob.type === "run") return { type: "run", commands: [knob.command] }; + if (knob.type === "open") + return { + type: "run", + commands: [model.fx === undefined ? "knobs sound" : `/patch${suffix}`], + }; + if (knob.type === "handled") return { type: "handled" }; + } + + const up = KEY_UP.has(value); + if (up || KEY_DOWN.has(value)) { + const by = up ? -1 : 1; + if (state.pane === "nodes") { + state.node = clampIndex(state.node + by, model.nodes.length); + state.port = 0; + if (!state.full) { + state.row = 0; + state.col = 0; + } + } else if (state.pane === "ports") + state.port = clampIndex(state.port + by, ports.length); + else if (state.pane === "matrix") + state.row = clampIndex(state.row + by, rows.length); + return { type: "handled" }; + } + const left = KEY_LEFT.has(value) && value !== "-"; + if (left || (KEY_RIGHT.has(value) && value !== "+")) { + const by = left ? -1 : 1; + if (state.pane === "matrix") + state.col = clampIndex(state.col + by, cols.length); + else if (state.pane === "nodes" && !left) state.pane = "ports"; + else if (state.pane === "ports" && left) state.pane = "nodes"; + else if (state.pane === "ports") state.pane = "matrix"; + return { type: "handled" }; + } + + // Matrix cell amount: [ ] 5 %, { } (⇧) 25 %, digits 10-90 % and 0 100 %. + const fine = value === "[" ? -0.05 : value === "]" ? 0.05 : undefined; + const coarse = KEY_COARSE_LEFT.has(value) + ? -0.25 + : KEY_COARSE_RIGHT.has(value) + ? 0.25 + : undefined; + const digit = /^[0-9]$/.test(value) ? Number(value) : undefined; + if ( + state.pane === "matrix" && + (fine !== undefined || coarse !== undefined || digit !== undefined) + ) { + if (!row || !col || row.kind === "macro") return { type: "handled" }; + if (col.kind !== "control") + return { + type: "note", + message: `${col.ref} is ${col.kind}: amounts scale cables into control ports`, + }; + const cable = cableAt(model, row.ref, col.ref); + if (!cable && !legalCable(model, row.ref, col.ref)) + return { type: "handled" }; + const amount = + digit !== undefined + ? digit === 0 + ? 1 + : digit / 10 + : (cable ? amountOf(cable) : 0) + (fine ?? coarse ?? 0); + return edit({ + type: "run", + commands: [amountCommand(model, row.ref, col.ref, amount)], + }); + } + + if (KEY_ENTER.has(value)) { + if (state.pane === "nodes") { + if (!node) return { type: "handled" }; + if (node.boundary) + return { + type: "note", + message: `${node.id}: ${model.specs.get(node.id)?.doc ?? "the patch boundary"}`, + }; + return edit({ type: "node", nodeId: node.id }); + } + if (state.pane === "ports") { + if (!port || port.kind === "param") { + return node && !node.boundary + ? edit({ type: "node", nodeId: node.id }) + : { type: "handled" }; + } + const other = port.links[0]; + if (!other) + return port.kind === "out" + ? wirePick(model, `${node!.id}.${port.name}`, edit) + : { type: "note", message: `${node!.id}.${port.name} has no cable` }; + const id = splitPort(other)[0]; + state.node = Math.max( + 0, + model.nodes.findIndex((n) => n.id === id), + ); + state.port = 0; + return { type: "handled" }; + } + if (state.pane === "matrix") { + if (!row || !col) return { type: "handled" }; + if (row.kind === "macro") + return { + type: "note", + message: `knob ${row.label}: m on a port maps it · patch macro`, + }; + const cable = cableAt(model, row.ref, col.ref); + if (cable) + return edit({ + type: "run", + commands: [`patch unwire ${row.ref} ${col.ref}${suffix}`], + }); + if (!legalCable(model, row.ref, col.ref)) + return { + type: "note", + message: `${row.ref} cannot feed ${col.ref}`, + }; + return edit({ + type: "run", + commands: [`patch wire ${row.ref} ${col.ref}${suffix}`], + }); + } + } + + switch (value) { + case "w": { + const from = + state.pane === "matrix" && row && row.kind !== "macro" + ? row.ref + : state.pane === "ports" && port?.kind === "out" + ? `${node!.id}.${port.name}` + : node + ? model.sources.find((s) => splitPort(s.ref)[0] === node.id)?.ref + : undefined; + if (!from) + return { + type: "note", + message: `${node?.id ?? "this node"} has no output`, + }; + return wirePick(model, from, edit); + } + case "x": + case "\u001b[3~": { + if (state.pane === "matrix") { + const cable = row && col ? cableAt(model, row.ref, col.ref) : undefined; + if (!cable) return { type: "handled" }; + return edit({ + type: "run", + commands: [`patch unwire ${cable.from} ${cable.to}${suffix}`], + }); + } + if (state.pane === "ports" && port && port.kind !== "param") { + const other = port.links[0]; + if (!other) return { type: "handled" }; + const self = `${node!.id}.${port.name}`; + const [from, to] = port.kind === "in" ? [other, self] : [self, other]; + return edit({ + type: "run", + commands: [`patch unwire ${from} ${to}${suffix}`], + }); + } + if (!node || node.boundary) + return { + type: "note", + message: `${node?.id ?? "it"} is the patch boundary`, + }; + return edit({ type: "run", commands: [`patch rm ${node.id}${suffix}`] }); + } + case "m": { + const ref = + state.pane === "ports" && port && port.kind !== "out" && port.mappable + ? `${node!.id}.${port.name}` + : state.pane === "matrix" && col?.kind === "control" + ? col.ref + : undefined; + if (!ref) + return { + type: "note", + message: + "m maps a number param or control input: pick one in the ports pane", + }; + return edit({ + type: "pick", + id: "patch-map", + title: `map ${ref} to a knob`, + items: mapItems(model, ref), + }); + } + case "g": { + if (!node || !node.either) + return { + type: "note", + message: `${node?.id ?? "it"} always runs ${node?.rate ?? "global"}`, + }; + return edit({ + type: "run", + commands: [ + `patch rate ${node.id} ${node.forced ? "voice" : "global"}${suffix}`, + ], + }); + } + } + if (value === "/" || value === "\u0003" || value === "\u000b") + return { type: "pass" }; + // Unmapped printable keys are swallowed; control keys keep their bindings. + return value.length === 1 && value >= " " && value !== "\u007f" + ? { type: "handled" } + : { type: "pass" }; +} + +function wirePick( + model: PatchModel, + from: string, + edit: (action: PatchAction) => PatchAction, +): PatchAction { + const targets = wireTargets(model, from); + if (targets.length === 0) + return { type: "note", message: `${from} has nowhere left to go` }; + return edit({ + type: "pick", + id: "patch-wire", + title: `wire ${from} to…`, + items: targets.map((to) => ({ + label: `${to.padEnd(20)} ${portKind(model, to, "in")}`, + value: `patch wire ${from} ${to}${fxSuffix(model.fx)}`, + })), + }); +} + +/** The knob index of a macro, for painters (undefined past the fourth). */ +export function macroKnob(index: number): KnobIndex | undefined { + return index < 4 ? (index as KnobIndex) : undefined; +} + +// ── paint ───────────────────────────────────────────────────────────── + +function cellPaint( + model: PatchModel, + row: MatrixRow, + col: MatrixCol, +): PaintCell { + const c = cell(model, row, col); + if (row.kind === "macro") + return c.mapped ? { kind: "macro" } : { kind: "none" }; + if (c.cable) + return { + kind: col.kind, + ...(c.cable.amount !== undefined ? { amount: c.cable.amount } : {}), + ...(c.dropped ? { dropped: true } : {}), + ...(c.sum ? { sum: true } : {}), + }; + return { kind: c.legal ? "legal" : "none" }; +} + +/** The selected cell in words, for the matrix's last row. */ +export function cellCaption(model: PatchModel, state: PatchViewState): string { + const { rows, cols } = matrixAxes(model, state); + const row = rows[state.row]; + const col = cols[state.col]; + if (!row || !col) return ""; + if (row.kind === "macro") { + const macro = model.patch.macros[row.macro!]!; + const mapped = macro.to.some((t) => t.port === col.ref); + return `knob ${row.label} ${mapped ? "turns" : "does not turn"} ${col.ref}`; + } + const c = cell(model, row, col); + if (c.cable) { + const amount = + col.kind === "control" ? ` · ${num(c.cable.amount ?? 1)} ([ ] 0-9)` : ""; + const notes = [ + c.dropped ? " · dropped by the compiler" : "", + c.sum ? " · summed over voices" : "", + ].join(""); + return `${row.ref} → ${col.ref}${amount}${notes} · enter unwires`; + } + return c.legal + ? `${row.ref} → ${col.ref} · enter wires` + : `${row.ref} cannot feed ${col.ref}`; +} + +/** The painter's view of the model at `state` (tui/patch.ts). */ +export function patchPaint( + model: PatchModel, + state: PatchViewState, + hint = PATCH_HINT, +): PatchPaint { + clampState(state, model); + const node = model.nodes[state.node]; + const ports = node ? portRows(model, node.id) : []; + const { rows, cols } = matrixAxes(model, state); + const patch = model.patch; + const plural = (n: number, word: string) => + `${n} ${word}${n === 1 ? "" : "s"}`; + const title = `${patch.name} (${[ + patch.role, + plural(patch.nodes.length, "node"), + plural(patch.cables.length, "cable"), + ...(patch.role === "instrument" && model.voices > 0 + ? [plural(model.voices, "voice")] + : []), + ].join(" · ")})`; + return { + title: `${model.track.id} ▸ ${title}`, + tag: model.preview + ? "preview · patch convert" + : model.ref !== undefined + ? `library ${model.ref} · patch detach` + : undefined, + pane: state.pane, + nodes: model.nodes.map((n) => ({ + id: n.id, + type: n.type === n.id ? "" : n.type, + rate: n.rate, + detail: n.detail, + boundary: n.boundary, + })), + node: state.node, + portsTitle: node + ? `${node.id} ${node.type === node.id ? "" : `${node.type} · `}${node.rate}` + : "", + ports: ports.map((port) => ({ + kind: port.kind, + name: port.name, + value: port.kind === "param" ? port.text : port.links.join(", "), + })), + port: state.port, + rows: rows.map((row) => row.label), + cols: cols.map((col) => col.ref), + cells: rows.map((row) => cols.map((col) => cellPaint(model, row, col))), + row: state.row, + col: state.col, + full: state.full, + caption: cellCaption(model, state), + warnings: model.warnings, + knobs: patchKnobs(model), + selected: state.knobs.selected, + macros: patch.macros.length, + hint, + }; +} diff --git a/test/pty-harness.ts b/test/pty-harness.ts index 009f4904..0d1ef332 100644 --- a/test/pty-harness.ts +++ b/test/pty-harness.ts @@ -42,7 +42,7 @@ export interface EditorState { busy: boolean; rev: number; track: string; - screen: "home" | "tape" | "play" | "sound" | "menu"; + screen: "home" | "tape" | "play" | "sound" | "menu" | "patch"; overlay: string | null; } diff --git a/test/sizes-lib.ts b/test/sizes-lib.ts index e4cf048d..a147f058 100644 --- a/test/sizes-lib.ts +++ b/test/sizes-lib.ts @@ -57,6 +57,7 @@ export const CHECK_SIZES: readonly Size[] = [ export const CHECK_SCENARIOS = [ "home-playing", "tape", + "patch", "drawer-knobs", "menu-depth", "help", @@ -205,6 +206,18 @@ export const SCENARIOS: readonly Scenario[] = [ marker: (text) => text.includes("range: "), focus: focusShown, }, + { + name: "patch", + async open(t) { + await ready(t); + await seed(t); + await t.type("/patch\r", "patch view opened"); + await t.until(() => t.vt.text().includes("patch bass"), "patch view"); + }, + // The header names the track; at any usable size it is the first row. + marker: (text) => text.includes("patch bass"), + focus: focusShown, + }, { name: "play-mode", async open(t) { diff --git a/tui/app.ts b/tui/app.ts index f175af73..e1e3980b 100644 --- a/tui/app.ts +++ b/tui/app.ts @@ -13,6 +13,7 @@ */ import { paintTape, type TapeView } from "./tape.ts"; +import { paintPatch, type PatchPaint } from "./patch.ts"; import { ActivityFeed, ERROR_FADE_MS, @@ -107,6 +108,8 @@ export interface AppView { play?: PlayHeaderView | undefined; /** TAPE (op1-ux §6): replaces the highway and the arrange strip. */ tape?: TapeView | undefined; + /** The patch view (patcher §7): replaces the highway and the arrange strip. */ + patch?: PatchPaint | undefined; /** Song master meter: integrated LUFS and true peak of the playing loop. */ loudness?: LoudnessView | undefined; /** Song sections over the timeline (0.5); no row when absent. */ @@ -1509,7 +1512,7 @@ export function composeFrame( hits, view.beat ?? resolveBeat(view.score, nowMs), ); - if (view.arrange && !view.tape && layout.highway.height > 4) { + if (view.arrange && !view.tape && !view.patch && layout.highway.height > 4) { paintArrangeStrip( buffer, layout.highway.y, @@ -1550,7 +1553,18 @@ export function composeFrame( ? { x: sideX, width: width - sideX } : undefined : { x: 0, width }; - if (side && view.tape) + if (side && view.patch) + paintPatch( + buffer, + { ...side, y: layout.highway.y, height: layout.highway.height }, + view.patch, + { + theme: ui.theme, + unicode: ui.capabilities.unicode, + ...(panel !== undefined ? {} : { hits }), + }, + ); + else if (side && view.tape) paintTape( buffer, { ...side, y: layout.highway.y, height: layout.highway.height }, diff --git a/tui/grammar.ts b/tui/grammar.ts index 84cda0d7..91acebbc 100644 --- a/tui/grammar.ts +++ b/tui/grammar.ts @@ -386,6 +386,29 @@ export const KEYS = { ], }, ], + patch: [ + { + title: "patch · nodes, ports, cables (each key echoes its command)", + rows: [ + ["↑ ↓", "move in the focused pane"], + ["tab ⇧tab", "next pane: nodes · ports · cables · knobs"], + ["← → ⇧", "nodes ⇄ ports · across cables · turn a knob (⇧ coarse)"], + ["enter", "node: its settings · cell: patch wire / unwire"], + ["[ ] { }", "cable amount −/+ 5 % (⇧ 25 %) · patch wire a b 0.6"], + ["1-9 0", "cable amount 10-90 % · 100 %"], + ["a", "add a node · patch add lfo"], + ["w", "wire to a legal input · patch wire env.out vcf.cutoff"], + ["x", "remove the cable or node · patch unwire · patch rm"], + ["m", "map the param to a knob · patch macro"], + ["g", "run the node per voice or once · patch rate lfo global"], + ["f", "the whole cable matrix / the node's neighbourhood"], + ["s", "save to the library · patch save acid-bass"], + ["space", "play / pause"], + ["/", "type a command, staying here"], + ["esc", "back home · patch off"], + ], + }, + ], chords: [ { title: "chord mode · number row latches", diff --git a/tui/patch.ts b/tui/patch.ts new file mode 100644 index 00000000..fe0edf11 --- /dev/null +++ b/tui/patch.ts @@ -0,0 +1,567 @@ +/** + * The patch view (patcher design §7.2), as a pure painter: the node list + * and the selected node's ports side by side, the cable matrix under them + * (rows are outputs, columns are inputs), the compiler's warnings, the + * four macro knobs and the gesture row. + * + * The caller (src/tui/patch-view.ts) builds a `PatchPaint` from the score; + * this module only lays it out, so every size is testable without a + * session. Colour is never the only cue (NO_COLOR, mono): the focused pane + * title is reversed, the selected row carries `▸` (ASCII `>`), a node's + * rate is `◆` voice / `●` global (`v` / `g`), and a cell's kind is its + * glyph: `~` audio, `▪` control with its amount, `♪` notes (`n`), `Σ` a + * voice sum (`S`), `✕` a cable the compiler dropped (`x`). + * + * Small terminals: below 10 body rows the matrix replaces the node and + * port lists while it has focus. Large ones: the lists stop at 32 and 64 + * columns, and the matrix shows more columns and rows. + */ +import { asciiHint } from "./grammar.ts"; +import type { HitMap } from "./hits.ts"; +import { paintKnobStrip, type KnobIndex, type KnobSlots } from "./knobs.ts"; +import type { CellBuffer } from "./screen.ts"; +import { displayWidth, truncate } from "./text.ts"; +import type { Style, Theme } from "./theme.ts"; + +export type PatchPaneId = "nodes" | "ports" | "matrix" | "knobs"; + +export type PaintNode = Readonly<{ + id: string; + type: string; + rate: "voice" | "global"; + detail: string; + boundary: boolean; +}>; + +export type PaintPort = Readonly<{ + kind: "param" | "in" | "out"; + name: string; + /** A param's value, or the cables (`← voice.pitch`, `→ vcf.in`). */ + value: string; +}>; + +export type PaintCell = Readonly<{ + kind: "audio" | "control" | "notes" | "macro" | "legal" | "none"; + /** A control cable's amount (absent: 1). */ + amount?: number | undefined; + dropped?: boolean | undefined; + sum?: boolean | undefined; +}>; + +export type PatchPaint = Readonly<{ + /** `acid-bass (instrument · 6 nodes · 9 cables · 16 voices)`. */ + title: string; + /** `preview` or `library acid-bass`: why the view is read-only. */ + tag?: string | undefined; + pane: PatchPaneId; + nodes: readonly PaintNode[]; + node: number; + /** `tone osc · voice`. */ + portsTitle: string; + ports: readonly PaintPort[]; + port: number; + rows: readonly string[]; + cols: readonly string[]; + /** `cells[row][col]`. */ + cells: readonly (readonly PaintCell[])[]; + row: number; + col: number; + full: boolean; + /** The selected cell in words: `env.out → vcf.cutoff · 0.6`. */ + caption: string; + warnings: readonly string[]; + knobs: KnobSlots; + selected: KnobIndex; + /** Any macro at all (else the knob row reads `m maps a param`). */ + macros: number; + hint: string; +}>; + +export type PatchPaintOptions = Readonly<{ + theme: Theme; + unicode: boolean; + hits?: HitMap | undefined; +}>; + +/** What the painter laid out, for tests. */ +export type PatchLayout = Readonly<{ + /** Lists shown (node and port panes). */ + lists: boolean; + /** Matrix rows and columns shown, and the first of each. */ + matrixRows: number; + matrixCols: number; + firstRow: number; + firstCol: number; +}>; + +/** Widest the node list and the ports pane grow. */ +export const PATCH_NODES_MAX = 44; +export const PATCH_PORTS_MAX = 64; +const NODES_MIN = 18; +/** Widest a matrix column and the row labels get. */ +const COLUMN_MAX = 10; + +/** ASCII spellings of the arrows the view's words carry. */ +function asciiText(text: string): string { + return asciiHint( + text + .replace(/▸/g, ">") + .replace(/→/g, "->") + .replace(/←/g, "<-") + .replace(/⇄/g, "<>"), + ); +} +const LABEL_MAX = 18; + +/** A cell's text (without the selection), at most `width - 1` cells. */ +export function cellText(cell: PaintCell, unicode: boolean): string { + if (cell.kind === "none") return ""; + if (cell.kind === "legal") return unicode ? "·" : "."; + let text: string; + if (cell.kind === "audio") text = "~"; + else if (cell.kind === "notes") text = unicode ? "♪" : "n"; + else { + const mark = unicode ? "▪" : "#"; + const amount = cell.amount ?? 1; + text = + cell.kind === "macro" || amount === 1 + ? mark + : `${mark}${amountText(amount)}`; + } + if (cell.sum) text += unicode ? "Σ" : "S"; + if (cell.dropped) text = `${unicode ? "✕" : "x"}${text}`; + return text; +} + +/** `.6`, `-.25`, `1`: an amount in at most four cells. */ +export function amountText(amount: number): string { + const rounded = Math.round(amount * 100) / 100; + if (Number.isInteger(rounded)) return String(rounded); + const text = String(rounded).replace(/^(-?)0\./, "$1."); + return text.slice(0, 4); +} + +function firstVisible(selected: number, count: number, room: number): number { + if (count <= room || room <= 0) return 0; + return Math.max(0, Math.min(selected - room + 1, count - room, selected)); +} + +export function paintPatch( + buffer: CellBuffer, + rect: Readonly<{ x: number; y: number; width: number; height: number }>, + view: PatchPaint, + options: PatchPaintOptions, +): PatchLayout { + const { theme, unicode } = options; + const roles = theme.roles; + const empty: PatchLayout = { + lists: false, + matrixRows: 0, + matrixCols: 0, + firstRow: 0, + firstCol: 0, + }; + if (rect.width < 20 || rect.height < 4) return empty; + const bottom = rect.y + rect.height; + const rule = unicode ? "─" : "-"; + const pointer = unicode ? "▸" : ">"; + const say = (text: string): string => (unicode ? text : asciiText(text)); + + // Header: the patch, and why it is read-only. + let y = rect.y; + const tag = view.tag ? ` [${say(view.tag)}]` : ""; + const head = truncate( + ` patch ${say(view.title)}`, + rect.width - displayWidth(tag) - 1, + ); + buffer.text(rect.x, y, head, { ...roles.text, bold: true }); + if (tag) buffer.text(rect.x + displayWidth(head), y, tag, roles.warning); + y += 1; + + // Footer, bottom up: the hint, the knobs, the first warning. + let footer = bottom; + footer -= 1; + buffer.text( + rect.x + 1, + footer, + truncate(say(view.hint), rect.width - 2), + roles.muted, + ); + if (footer - y >= 5) { + footer -= 1; + if (view.macros > 0) + paintKnobStrip( + buffer, + rect.x + 1, + footer, + rect.width - 2, + view.knobs, + view.selected, + { theme, unicode }, + ); + else + buffer.text( + rect.x + 1, + footer, + truncate("no knobs yet · m on a param maps it to one", rect.width - 2), + roles.faint, + ); + if (view.pane === "knobs") + buffer.text(rect.x, footer, pointer, { ...roles.text, bold: true }); + } + if (view.warnings.length > 0 && footer - y >= 5) { + footer -= 1; + const count = + view.warnings.length > 1 ? ` (+${view.warnings.length - 1} more)` : ""; + buffer.text( + rect.x + 1, + footer, + truncate( + `${unicode ? "⚠" : "!"} ${say(view.warnings[0]!)}${count}`, + rect.width - 2, + ), + roles.warning, + ); + } + + // Body: lists over the matrix, or one of them when short. + const body = footer - y; + if (body <= 0) return empty; + const matrixOnly = body < 10 && view.pane === "matrix"; + const listsOnly = body < 10 && !matrixOnly; + const listRows = listsOnly + ? body + : matrixOnly + ? 0 + : Math.max( + 4, + Math.min( + Math.max(view.nodes.length, view.ports.length) + 1, + Math.ceil(body * 0.45), + ), + ); + if (listRows > 0) paintLists(buffer, rect, y, listRows, view, options); + y += listRows; + if (listsOnly || y >= footer) return { ...empty, lists: true }; + if (listRows > 0) { + buffer.text(rect.x, y, rule.repeat(rect.width), roles.border); + y += 1; + } + const layout = paintMatrix( + buffer, + { x: rect.x, y, width: rect.width, height: footer - y }, + view, + options, + ); + return { ...layout, lists: listRows > 0 }; +} + +function paneTitle( + buffer: CellBuffer, + x: number, + y: number, + text: string, + focused: boolean, + theme: Theme, + width: number, +): void { + buffer.text( + x, + y, + truncate(` ${text} `, width), + focused + ? { ...theme.roles.text, bold: true, reverse: true } + : theme.roles.faint, + ); +} + +function paintLists( + buffer: CellBuffer, + rect: Readonly<{ x: number; width: number }>, + top: number, + rows: number, + view: PatchPaint, + options: PatchPaintOptions, +): void { + const { theme, unicode } = options; + const roles = theme.roles; + const nodesWidth = Math.min( + PATCH_NODES_MAX, + Math.max(NODES_MIN, Math.floor(rect.width * 0.38)), + ); + const portsX = rect.x + nodesWidth + 1; + const portsWidth = Math.min( + PATCH_PORTS_MAX, + rect.x + rect.width - portsX - 1, + ); + const pointer = unicode ? "▸" : ">"; + for (let row = 0; row < rows; row += 1) + buffer.text( + rect.x + nodesWidth, + top + row, + unicode ? "│" : "|", + roles.border, + ); + + // Nodes, in signal order. + paneTitle( + buffer, + rect.x, + top, + "NODES", + view.pane === "nodes", + theme, + nodesWidth, + ); + const nodeRoom = rows - 1; + const firstNode = firstVisible(view.node, view.nodes.length, nodeRoom); + view.nodes.slice(firstNode, firstNode + nodeRoom).forEach((node, offset) => { + const index = firstNode + offset; + const y = top + 1 + offset; + const selected = index === view.node; + const rate = + node.rate === "voice" ? (unicode ? "◆" : "v") : unicode ? "●" : "g"; + buffer.text(rect.x, y, selected ? pointer : " ", { + ...roles.text, + bold: true, + }); + // Ids take what they need, up to 20 columns or half the pane. + const longest = Math.max(...view.nodes.map((n) => displayWidth(n.id))); + const idWidth = Math.max( + Math.min(8, nodesWidth - 4), + Math.min(longest, 20, Math.floor((nodesWidth - 4) / 2)), + ); + const id = truncate(node.id, idWidth).padEnd(idWidth); + buffer.text( + rect.x + 1, + y, + id, + selected && view.pane === "nodes" + ? { ...roles.selected, reverse: true } + : node.boundary + ? roles.muted + : roles.text, + ); + const rest = nodesWidth - 2 - idWidth; + if (rest >= 4) { + buffer.text( + rect.x + 2 + idWidth, + y, + rate, + node.rate === "voice" ? roles.knob1 : roles.knob4, + ); + const detail = node.detail ? ` ${node.detail}` : ""; + buffer.text( + rect.x + 4 + idWidth, + y, + truncate(`${node.type}${detail}`, rest - 3), + roles.faint, + ); + } + }); + + // The selected node's params and ports. + if (portsWidth < 10) return; + paneTitle( + buffer, + portsX, + top, + view.portsTitle, + view.pane === "ports", + theme, + portsWidth, + ); + const portRoom = rows - 1; + const firstPort = firstVisible(view.port, view.ports.length, portRoom); + const nameWidth = Math.min( + 10, + Math.max(4, ...view.ports.map((port) => displayWidth(port.name) + 1)), + ); + view.ports.slice(firstPort, firstPort + portRoom).forEach((port, offset) => { + const index = firstPort + offset; + const y = top + 1 + offset; + const selected = index === view.port; + buffer.text(portsX, y, selected ? pointer : " ", { + ...roles.text, + bold: true, + }); + const mark = + port.kind === "in" + ? unicode + ? "←" + : "<-" + : port.kind === "out" + ? unicode + ? "→" + : "->" + : " "; + buffer.text( + portsX + 1, + y, + truncate(port.name, nameWidth).padEnd(nameWidth), + selected && view.pane === "ports" + ? { ...roles.selected, reverse: true } + : port.kind === "param" + ? roles.text + : roles.muted, + ); + buffer.text( + portsX + 2 + nameWidth, + y, + truncate( + port.kind === "param" ? port.value : `${mark} ${port.value || "·"}`, + portsWidth - nameWidth - 3, + ), + port.kind === "param" ? roles.text : roles.faint, + ); + }); +} + +function cellStyle(cell: PaintCell, theme: Theme): Style { + const roles = theme.roles; + if (cell.dropped) return roles.error; + switch (cell.kind) { + case "audio": + return roles.knob2; + case "control": + return roles.knob1; + case "notes": + return roles.knob3; + case "macro": + return roles.knob4; + default: + return roles.faint; + } +} + +function paintMatrix( + buffer: CellBuffer, + rect: Readonly<{ x: number; y: number; width: number; height: number }>, + view: PatchPaint, + options: PatchPaintOptions, +): Omit { + const { theme, unicode } = options; + const roles = theme.roles; + const none = { matrixRows: 0, matrixCols: 0, firstRow: 0, firstCol: 0 }; + if (rect.height < 2) return none; + const focused = view.pane === "matrix"; + const label = view.full ? "CABLES (all)" : "CABLES"; + const labelWidth = Math.min( + LABEL_MAX, + Math.max( + displayWidth(label) + 2, + ...view.rows.map((row) => displayWidth(row) + 2), + ), + ); + paneTitle(buffer, rect.x, rect.y, label, focused, theme, labelWidth); + // The last row: the selected cell in words, or the legend. + const legendRow = rect.height >= 4; + const gridRows = rect.height - 1 - (legendRow ? 1 : 0); + if (legendRow) { + const legend = unicode + ? "◆ voice ● global ~ audio ▪ control ♪ notes Σ voice sum ✕ dropped" + : "v voice g global ~ audio # control n notes S voice sum x dropped"; + buffer.text( + rect.x + 1, + rect.y + rect.height - 1, + truncate( + (unicode ? (s: string) => s : asciiText)( + focused && view.caption ? view.caption : legend, + ), + rect.width - 2, + ), + focused ? roles.text : roles.faint, + ); + } + if (view.rows.length === 0 || view.cols.length === 0) { + buffer.text( + rect.x + labelWidth, + rect.y, + truncate( + "no cables here · w wires the selected output · f the full matrix", + rect.width - labelWidth - 1, + ), + roles.faint, + ); + return none; + } + // Columns: page so the selected one shows, as many as fit. A terminal + // wide enough for every full label shows them whole. + const room = rect.width - labelWidth; + const whole = view.cols.map((col) => Math.max(5, displayWidth(col) + 1)); + const widths = + whole.reduce((sum, w) => sum + w, 0) <= room + ? whole + : whole.map((w) => Math.min(COLUMN_MAX, w)); + let firstCol = 0; + const fits = (from: number, to: number): boolean => { + let used = 0; + for (let k = from; k <= to; k += 1) used += widths[k]!; + return used <= room; + }; + while (firstCol < view.col && !fits(firstCol, view.col)) firstCol += 1; + let lastCol = firstCol; + let used = 0; + while (lastCol < view.cols.length && used + widths[lastCol]! <= room) { + used += widths[lastCol]!; + lastCol += 1; + } + const firstRow = firstVisible(view.row, view.rows.length, gridRows); + // Column headers, pinned. + let x = rect.x + labelWidth; + for (let k = firstCol; k < lastCol; k += 1) { + const selected = focused && k === view.col; + buffer.text( + x, + rect.y, + truncate(view.cols[k]!, widths[k]! - 1), + selected ? { ...roles.text, bold: true, reverse: true } : roles.muted, + ); + x += widths[k]!; + } + // Rows, labels pinned. + const shown = view.rows.slice(firstRow, firstRow + gridRows); + shown.forEach((name, offset) => { + const index = firstRow + offset; + const y = rect.y + 1 + offset; + const selectedRow = focused && index === view.row; + buffer.text(rect.x, y, selectedRow ? (unicode ? "▸" : ">") : " ", { + ...roles.text, + bold: true, + }); + buffer.text( + rect.x + 1, + y, + truncate(name, labelWidth - 2), + selectedRow ? { ...roles.text, bold: true, reverse: true } : roles.text, + ); + let cx = rect.x + labelWidth; + for (let k = firstCol; k < lastCol; k += 1) { + const cell = view.cells[index]?.[k] ?? { kind: "none" as const }; + const text = truncate(cellText(cell, unicode), widths[k]! - 1, ""); + const selected = selectedRow && k === view.col; + const style = cellStyle(cell, theme); + if (selected) { + // The cursor shows on an empty cell too: a reversed `·` or blank. + const shownText = text || " "; + buffer.text( + cx, + y, + shownText.padEnd(Math.max(1, displayWidth(shownText))), + { + ...style, + reverse: true, + bold: true, + }, + ); + } else if (text) buffer.text(cx, y, text, style); + cx += widths[k]!; + } + }); + return { + matrixRows: shown.length, + matrixCols: lastCol - firstCol, + firstRow, + firstCol, + }; +} From 39d1e60bf7fb8e741e8ec4e767ebd839b45fd615 Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sat, 10 Oct 2026 16:31:06 -0400 Subject: [PATCH 2/6] =?UTF-8?q?patcher:=20a=20cable=20wired=20elsewhere=20?= =?UTF-8?q?lands=20the=20patch=20view=20on=20its=20cell=20(=C2=A77.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- DAWG.md | 2 +- src/main.ts | 22 ++++++++++++++++++++-- src/tui/patch-view.test.ts | 21 +++++++++++++++++++++ src/tui/patch-view.ts | 30 ++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/DAWG.md b/DAWG.md index 3f603290..8741ee85 100644 --- a/DAWG.md +++ b/DAWG.md @@ -1663,7 +1663,7 @@ Copy, move, clear and reverse never touch tempo or meter points (except that `in `Ctrl-T` or `/tape` opens TAPE in place of the highway: every track as a row across the bars, each cell a density glyph (` ▁▂▃▄▅▆▇█`, the notes starting there weighted by velocity; a held note `▁`, an audio clip `▃`, empty `·`). Above the rows sit the bar ruler with the playhead `▼` and the loop brackets `[══]` read from `score.loop`, and the sections (`▀name▀`, the looped one reversed). The focused row carries `›`, a muted row is dim, and color is never the only cue (NO_COLOR and the mono theme keep every glyph). `-` and `=` zoom between a bar, a beat and half a beat a cell, and the view scrolls to keep the playhead in sight. The highway stays home: `Esc` or `Ctrl-T` again goes back, and `dawg pane tape` opens a pane on it. -`/patch` (Ctrl-K Sound › patch › Edit patch) opens the patch view of the focused track in place of the highway; `/patch --fx ` opens one of its effect patches (Effects › " patch" › Edit effect patch). It lists the nodes in signal order (`◆` per voice, `●` once), the selected node's ports, and a cable matrix with outputs down and inputs across, pinned headers, and only the selected node's neighbourhood until `f` shows the whole matrix. A cell shows a cable's amount; a cable the compiler drops (an unreachable node, a cycle) is marked and its warning sits above the knobs. `tab` moves between the panes, `enter` opens a node's settings in the drawer or wires and unwires a cell, `[ ] { }` and `1`-`0` set a cable's amount, and `a`, `w`, `x`, `m` and `g` add a node, wire to a legal input, remove, map a param to a knob, and switch a node between per voice and once. Every key runs a typed `patch …` command that echoes, so the receipt teaches the words. The patch's first four macros are the four knobs (`KNOB_MAPS` row `sound:patch`). A track that is not a patch shows as a preview of its engine (`patch convert` makes it one), and a library patch is read-only apart from its knobs. `Esc` goes back, and `dawg pane patch [track]` opens a pane on it. +`/patch` (Ctrl-K Sound › patch › Edit patch) opens the patch view of the focused track in place of the highway; `/patch --fx ` opens one of its effect patches (Effects › " patch" › Edit effect patch). It lists the nodes in signal order (`◆` per voice, `●` once), the selected node's ports, and a cable matrix with outputs down and inputs across, pinned headers, and only the selected node's neighbourhood until `f` shows the whole matrix. A cell shows a cable's amount; a cable the compiler drops (an unreachable node, a cycle) is marked and its warning sits above the knobs. `tab` moves between the panes, `enter` opens a node's settings in the drawer or wires and unwires a cell, `[ ] { }` and `1`-`0` set a cable's amount, and `a`, `w`, `x`, `m` and `g` add a node, wire to a legal input, remove, map a param to a knob, and switch a node between per voice and once. Every key runs a typed `patch …` command that echoes, so the receipt teaches the words. The patch's first four macros are the four knobs (`KNOB_MAPS` row `sound:patch`). A track that is not a patch shows as a preview of its engine (`patch convert` makes it one), and a library patch is read-only apart from its knobs. A cable wired anywhere else (a typed command, the agent, another pane) moves the selection to its cell, so show-me lands where the change happened. `Esc` goes back, and `dawg pane patch [track]` opens a pane on it. The footer is the range line (`range: bass · bars 5–6 (loop)`, the loop range, else the section, else the bar under the playhead) with the clipboard chip, the four knobs (● playhead, ▲ loop length, ■ tempo, ◆ volume, from `tui/knobs.ts`; `↑` `↓` pick, `←` `→` turn, `⇧` coarse, `Enter` opens the drawer) and the key hints. Every key echoes and runs a typed command, so TAPE teaches the range commands above: `c` (`C` all tracks) is `copy bass 5-6`, `x` is `copy` then `clear`, `v` pastes at the playhead's bar (`V` inserts), `delete` clears, `~` reverses, `\` loops the section here (again: off), `<` `>` slide the loop, `[` `]` `{` `}` move its end and start, `,` `.` jump between sections, `s` `S` split and join the section, `h` `H` mute and solo, and `1`-`9` or `Tab` focus a track row (number keys pick tracks on TAPE, not knobs). The first paste after a cut lands as one `move`, one revision. The clipboard is per pane and kept in `.dawg/clipboard/` next to the session, so it survives a restart. With the mouse, a click on the ruler jumps, a drag on it sets the loop, a click on a row focuses it and a drag along a row sets the loop over those bars; the wheel walks the playhead a bar at a time. Other panes on a track show in the right gutter (`B`, `C●` while that pane records). Score edits commit like any other command, so other windows and the agent see them. diff --git a/src/main.ts b/src/main.ts index 2fba92cf..be5b8673 100644 --- a/src/main.ts +++ b/src/main.ts @@ -359,6 +359,8 @@ import { import { tapeView } from "./tui/tape-view.ts"; import { clampState, + focusCable, + newCables, patchKey, patchModel, patchPaint, @@ -367,6 +369,7 @@ import { } from "./tui/patch-view.ts"; import { nodeMenuId } from "./tui/patch-menu.ts"; import type { PatchPaint } from "../tui/patch.ts"; +import type { Cable } from "../core/patch.ts"; import { PromptQueue } from "./tui/prompt-queue.ts"; import { TAPE_ZOOMS, type TapeView, type TapeZoom } from "../tui/tape.ts"; import type { HitTarget } from "../tui/hits.ts"; @@ -900,7 +903,12 @@ let rangeClipboard: RangeClipboard | undefined; * patch, the focused pane, the node, port and matrix cell, the knob). * Each key runs a typed `patch …` command. */ -const patchView: { on: boolean; state: PatchViewState } = { +const patchView: { + on: boolean; + state: PatchViewState; + /** The cables last painted, so a new one (typed, agent, other pane) is shown. */ + cables?: Readonly<{ key: string; list: readonly Cable[] }> | undefined; +} = { on: false, state: patchViewState(), }; @@ -5789,8 +5797,10 @@ async function enterPatch(fx?: string): Promise { closeFader(); if (menu.open) menu.close(); if (tape.on) exitTape(); - if (!patchView.on || patchView.state.fx !== fx) + if (!patchView.on || patchView.state.fx !== fx) { patchView.state = patchViewState(fx); + patchView.cables = undefined; + } patchView.on = true; const patch = model.patch; const what = model.preview @@ -5813,6 +5823,14 @@ function exitPatch(): Receipt { function currentPatchPaint(value: TrackScore): PatchPaint | undefined { const model = patchModel(value, requestedTrack, patchView.state.fx); if (typeof model === "string") return undefined; + const cables = model.patch.cables; + const key = `${model.track.id} ${model.fx ?? ""}`; + const before = patchView.cables; + patchView.cables = { key, list: cables }; + if (before?.key === key && before.list !== cables) { + const added = newCables(before.list, cables).at(-1); + if (added) focusCable(patchView.state, model, added); + } clampState(patchView.state, model); return patchPaint(model, patchView.state); } diff --git a/src/tui/patch-view.test.ts b/src/tui/patch-view.test.ts index c97e6577..34211c4e 100644 --- a/src/tui/patch-view.test.ts +++ b/src/tui/patch-view.test.ts @@ -10,7 +10,9 @@ import { CellBuffer } from "../../tui/screen.ts"; import { PATCH_NODES_MAX, paintPatch } from "../../tui/patch.ts"; import { getTheme } from "../../tui/theme.ts"; import { + focusCable, legalCable, + newCables, matrixAxes, patchKey, patchModel, @@ -388,3 +390,22 @@ describe("paintPatch", () => { expect(() => paint(w, h, m, patchViewState())).not.toThrow(); }); }); + +describe("show-me (§7.5)", () => { + test("a cable wired elsewhere puts the selection on its cell", () => { + const m = model(acid()); + const state = patchViewState(); + const last = m.patch.cables.at(-1)!; + const before = m.patch.cables.slice(0, -1); + expect(newCables(before, m.patch.cables)).toEqual([last]); + expect(newCables(m.patch.cables, m.patch.cables)).toEqual([]); + expect(focusCable(state, m, last)).toBe(true); + expect(state.pane).toBe("matrix"); + const { rows, cols } = matrixAxes(m, state); + expect(rows[state.row]!.ref).toBe(last.from); + expect(cols[state.col]!.ref).toBe(last.to); + expect(focusCable(state, m, { from: "nope.out", to: "out.in" })).toBe( + false, + ); + }); +}); diff --git a/src/tui/patch-view.ts b/src/tui/patch-view.ts index f6b4463b..31efbdf6 100644 --- a/src/tui/patch-view.ts +++ b/src/tui/patch-view.ts @@ -418,6 +418,36 @@ export function matrixAxes( return { rows, cols }; } +/** + * Put the selection on a cable's cell (§7.5: show-me lands on the cell + * the agent, or another pane, just wired): its source node, the matrix + * pane, and its row and column. False when the cable is not in the patch. + */ +export function focusCable( + state: PatchViewState, + model: PatchModel, + cable: Readonly<{ from: string; to: string }>, +): boolean { + const node = model.nodes.findIndex((n) => n.id === splitPort(cable.from)[0]); + if (node < 0) return false; + const next = { ...state, node }; + const { rows, cols } = matrixAxes(model, next); + const row = rows.findIndex((r) => r.ref === cable.from); + const col = cols.findIndex((c) => c.ref === cable.to); + if (row < 0 || col < 0) return false; + Object.assign(state, { node, row, col, pane: "matrix" }); + return true; +} + +/** Cables in `now` and not in `before`, by from and to (ids may be new). */ +export function newCables( + before: readonly Cable[], + now: readonly Cable[], +): Cable[] { + const seen = new Set(before.map((c) => `${c.from} ${c.to}`)); + return now.filter((c) => !seen.has(`${c.from} ${c.to}`)); +} + function clampIndex(index: number, length: number): number { return Math.max(0, Math.min(length - 1, index)); } From e73f0614918a243d2ffd30fb740deecbd802e79f Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sat, 10 Oct 2026 16:41:17 -0400 Subject: [PATCH 3/6] =?UTF-8?q?patcher:=20space=20auditions=20in=20the=20p?= =?UTF-8?q?atch=20view;=20the=20add=20list=20hears=20each=20node=20(=C2=A7?= =?UTF-8?q?7.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- DAWG.md | 2 +- src/main.ts | 11 +++++++---- src/tui/audition.ts | 2 +- src/tui/patch-view.test.ts | 14 ++++++++++++++ src/tui/patch-view.ts | 7 ++++--- tui/grammar.ts | 2 +- 6 files changed, 28 insertions(+), 10 deletions(-) diff --git a/DAWG.md b/DAWG.md index 8741ee85..7898bc69 100644 --- a/DAWG.md +++ b/DAWG.md @@ -1663,7 +1663,7 @@ Copy, move, clear and reverse never touch tempo or meter points (except that `in `Ctrl-T` or `/tape` opens TAPE in place of the highway: every track as a row across the bars, each cell a density glyph (` ▁▂▃▄▅▆▇█`, the notes starting there weighted by velocity; a held note `▁`, an audio clip `▃`, empty `·`). Above the rows sit the bar ruler with the playhead `▼` and the loop brackets `[══]` read from `score.loop`, and the sections (`▀name▀`, the looped one reversed). The focused row carries `›`, a muted row is dim, and color is never the only cue (NO_COLOR and the mono theme keep every glyph). `-` and `=` zoom between a bar, a beat and half a beat a cell, and the view scrolls to keep the playhead in sight. The highway stays home: `Esc` or `Ctrl-T` again goes back, and `dawg pane tape` opens a pane on it. -`/patch` (Ctrl-K Sound › patch › Edit patch) opens the patch view of the focused track in place of the highway; `/patch --fx ` opens one of its effect patches (Effects › " patch" › Edit effect patch). It lists the nodes in signal order (`◆` per voice, `●` once), the selected node's ports, and a cable matrix with outputs down and inputs across, pinned headers, and only the selected node's neighbourhood until `f` shows the whole matrix. A cell shows a cable's amount; a cable the compiler drops (an unreachable node, a cycle) is marked and its warning sits above the knobs. `tab` moves between the panes, `enter` opens a node's settings in the drawer or wires and unwires a cell, `[ ] { }` and `1`-`0` set a cable's amount, and `a`, `w`, `x`, `m` and `g` add a node, wire to a legal input, remove, map a param to a knob, and switch a node between per voice and once. Every key runs a typed `patch …` command that echoes, so the receipt teaches the words. The patch's first four macros are the four knobs (`KNOB_MAPS` row `sound:patch`). A track that is not a patch shows as a preview of its engine (`patch convert` makes it one), and a library patch is read-only apart from its knobs. A cable wired anywhere else (a typed command, the agent, another pane) moves the selection to its cell, so show-me lands where the change happened. `Esc` goes back, and `dawg pane patch [track]` opens a pane on it. +`/patch` (Ctrl-K Sound › patch › Edit patch) opens the patch view of the focused track in place of the highway; `/patch --fx ` opens one of its effect patches (Effects › " patch" › Edit effect patch). It lists the nodes in signal order (`◆` per voice, `●` once), the selected node's ports, and a cable matrix with outputs down and inputs across, pinned headers, and only the selected node's neighbourhood until `f` shows the whole matrix. A cell shows a cable's amount; a cable the compiler drops (an unreachable node, a cycle) is marked and its warning sits above the knobs. `tab` moves between the panes, `enter` opens a node's settings in the drawer or wires and unwires a cell, `[ ] { }` and `1`-`0` set a cable's amount, and `a`, `w`, `x`, `m` and `g` add a node, wire to a legal input, remove, map a param to a knob, and switch a node between per voice and once. `space` loops the track solo, and while it loops each row of the add list is heard as you move (`enter` keeps, `esc` reverts). Every key runs a typed `patch …` command that echoes, so the receipt teaches the words. The patch's first four macros are the four knobs (`KNOB_MAPS` row `sound:patch`). A track that is not a patch shows as a preview of its engine (`patch convert` makes it one), and a library patch is read-only apart from its knobs. A cable wired anywhere else (a typed command, the agent, another pane) moves the selection to its cell, so show-me lands where the change happened. `Esc` goes back, and `dawg pane patch [track]` opens a pane on it. The footer is the range line (`range: bass · bars 5–6 (loop)`, the loop range, else the section, else the bar under the playhead) with the clipboard chip, the four knobs (● playhead, ▲ loop length, ■ tempo, ◆ volume, from `tui/knobs.ts`; `↑` `↓` pick, `←` `→` turn, `⇧` coarse, `Enter` opens the drawer) and the key hints. Every key echoes and runs a typed command, so TAPE teaches the range commands above: `c` (`C` all tracks) is `copy bass 5-6`, `x` is `copy` then `clear`, `v` pastes at the playhead's bar (`V` inserts), `delete` clears, `~` reverses, `\` loops the section here (again: off), `<` `>` slide the loop, `[` `]` `{` `}` move its end and start, `,` `.` jump between sections, `s` `S` split and join the section, `h` `H` mute and solo, and `1`-`9` or `Tab` focus a track row (number keys pick tracks on TAPE, not knobs). The first paste after a cut lands as one `move`, one revision. The clipboard is per pane and kept in `.dawg/clipboard/` next to the session, so it survives a restart. With the mouse, a click on the ruler jumps, a drag on it sets the loop, a click on a row focuses it and a drag along a row sets the loop over those bars; the wheel walks the playhead a bar at a time. Other panes on a track show in the right gutter (`B`, `C●` while that pane records). Score edits commit like any other command, so other windows and the agent see them. diff --git a/src/main.ts b/src/main.ts index be5b8673..b014b222 100644 --- a/src/main.ts +++ b/src/main.ts @@ -881,7 +881,7 @@ let monitorEngine: AudioEngine | undefined; /** The audition loop and staged edits (src/tui/audition.ts), made lazily. */ let auditionLoop: Audition | undefined; /** Pickers that host the audition loop (Space, `a`, `c`, hover). */ -const AUDITION_PICKERS = new Set(["kit", "pattern", "try"]); +const AUDITION_PICKERS = new Set(["kit", "pattern", "try", "patch-add"]); const TRY_USAGE = "/try · /try fx reverb mix 0.6 · /try agent on|off"; /** Whether the agent's preview_sound plays its snippet in this window. */ @@ -5875,7 +5875,10 @@ function patchInput(value: string): boolean { tui.openPicker({ id: action.id, title: action.title, - hint: HINTS.list, + // The add list hears each node on the loop (enter keeps, esc + // reverts), as /try and the kit list do. + hint: action.id === "patch-add" ? HINTS.audition : HINTS.list, + audition: action.id === "patch-add", items: action.items.map((item) => ({ label: item.label, value: item.value, @@ -5889,8 +5892,8 @@ function patchInput(value: string): boolean { case "note": tui.activity.pushCard(action.message, { tone: "info" }); return true; - case "transport": - void toggleTransport().catch((error: unknown) => transportFailed(error)); + case "audition": + void auditionKeyPressed("loop"); return true; case "keys": showKeys(); diff --git a/src/tui/audition.ts b/src/tui/audition.ts index 37398373..b63ec293 100644 --- a/src/tui/audition.ts +++ b/src/tui/audition.ts @@ -528,7 +528,7 @@ export function auditionKey(value: string): AuditionKey | undefined { * before. */ const STAGEABLE = - /^\/?(?:(clip|fx|effects|filter|lowpass|vocoder(?!\s+new\s*$)|formant|vowel|synth|string|bowed|modal|wind|sing|note\s+vowel|wt|wavetable|grain|kit|instrument|vol|volume|pan|gain|speed|warpmode|root|pattern|euclid|art|articulation|bend|vibrato|glide|portamento|velcurve|vel-curve|pedal|sustain|humanize|tuning|tune|master|keys|piano|epiano|rhodes|wurli|wurlitzer|clav|clavinet)\s+\S|pack\s+use\s+\S|(?:piano|grand|upright|felt|honkytonk|prepared|ballad)(?:\s+piano)?\s*$|(?:epiano|rhodes|suitcase|dyno|wurli|wurlitzer|clav|clavinet|funkclav)\s*$|(?:tonewheel|hammond|b3|gospel|jazzorgan|combo|farfisa|vox|pipe|church|pipeorgan|churchorgan|flutes|cornet|reeds|celeste)(?:\s+\S.*)?$|rotary\s+(?:slow|fast|stop)\s*$)/i; + /^\/?(?:(clip|fx|effects|filter|lowpass|vocoder(?!\s+new\s*$)|formant|vowel|synth|string|bowed|modal|wind|sing|note\s+vowel|wt|wavetable|grain|kit|instrument|vol|volume|pan|gain|speed|warpmode|root|pattern|euclid|art|articulation|bend|vibrato|glide|portamento|velcurve|vel-curve|pedal|sustain|humanize|tuning|tune|master|keys|piano|epiano|rhodes|wurli|wurlitzer|clav|clavinet)\s+\S|pack\s+use\s+\S|(?:piano|grand|upright|felt|honkytonk|prepared|ballad)(?:\s+piano)?\s*$|(?:epiano|rhodes|suitcase|dyno|wurli|wurlitzer|clav|clavinet|funkclav)\s*$|(?:tonewheel|hammond|b3|gospel|jazzorgan|combo|farfisa|vox|pipe|church|pipeorgan|churchorgan|flutes|cornet|reeds|celeste)(?:\s+\S.*)?$|rotary\s+(?:slow|fast|stop)\s*$|patch\s+(?:add|set|knob|wire|unwire|rm|macro|rate)\s+\S)/i; /** Subcommands that list or show instead of changing the sound. */ const READ_ONLY = /^\/?\S+\s+(list|show|info|help|measure|meter|presets?)\s*$/i; /** `master ` alone only shows the unit. */ diff --git a/src/tui/patch-view.test.ts b/src/tui/patch-view.test.ts index 34211c4e..a46ebd1d 100644 --- a/src/tui/patch-view.test.ts +++ b/src/tui/patch-view.test.ts @@ -9,6 +9,7 @@ import { createScore, type TrackScore } from "../../core/score.ts"; import { CellBuffer } from "../../tui/screen.ts"; import { PATCH_NODES_MAX, paintPatch } from "../../tui/patch.ts"; import { getTheme } from "../../tui/theme.ts"; +import { isStageable } from "./audition.ts"; import { focusCable, legalCable, @@ -409,3 +410,16 @@ describe("show-me (§7.5)", () => { ); }); }); + +describe("audition (§7 a, space)", () => { + test("space is the audition loop, and every add-list row stages", () => { + const m = model(acid()); + expect(keys(m, patchViewState(), " ")).toEqual({ type: "audition" }); + const pick = keys(m, patchViewState(), "a"); + if (pick.type !== "pick") throw new Error(pick.type); + expect(pick.id).toBe("patch-add"); + for (const item of pick.items) expect(isStageable(item.value)).toBe(true); + expect(isStageable("patch save mine")).toBe(false); + expect(isStageable("patch list")).toBe(false); + }); +}); diff --git a/src/tui/patch-view.ts b/src/tui/patch-view.ts index 31efbdf6..e51554c2 100644 --- a/src/tui/patch-view.ts +++ b/src/tui/patch-view.ts @@ -9,7 +9,7 @@ * port: jump to the node on its other end * [ ] { } cell amount −/+ 5 % (⇧ 25 %) · 1-9 0: 10-90 %, 100 % * a w x m g add node · wire to… · remove · map to a knob · voice/global - * f s space full matrix · save · play esc back home + * f s space full matrix · save · audition esc back home * * Pure: `patchModel` reads the score, `patchKey` turns a key into what to * run, and every edit is a typed `patch …` command (`--fx ` on an @@ -554,7 +554,8 @@ export type PatchAction = | Readonly<{ type: "prefill"; text: string }> | Readonly<{ type: "note"; message: string }> | Readonly<{ type: "handled" }> - | Readonly<{ type: "transport" }> + /** Space: the audition loop (the track solo; the add list hears each row). */ + | Readonly<{ type: "audition" }> | Readonly<{ type: "keys" }> | Readonly<{ type: "exit" }> /** Not a patch-view key: the prompt, ctrl keys. */ @@ -674,7 +675,7 @@ export function patchKey( case "\u001b": return { type: "exit" }; case " ": - return { type: "transport" }; + return { type: "audition" }; case "?": return { type: "keys" }; case "\t": diff --git a/tui/grammar.ts b/tui/grammar.ts index 91acebbc..0bccbb22 100644 --- a/tui/grammar.ts +++ b/tui/grammar.ts @@ -403,7 +403,7 @@ export const KEYS = { ["g", "run the node per voice or once · patch rate lfo global"], ["f", "the whole cable matrix / the node's neighbourhood"], ["s", "save to the library · patch save acid-bass"], - ["space", "play / pause"], + ["space", "audition loop (the track solo)"], ["/", "type a command, staying here"], ["esc", "back home · patch off"], ], From 28905278d041cac92e544a9be65ae847c3d1b5c7 Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sat, 10 Oct 2026 16:42:29 -0400 Subject: [PATCH 4/6] =?UTF-8?q?patcher:=20ctrl-k=20patch=20row=20table=20t?= =?UTF-8?q?est=20and=2080x24=20frame=20snapshots=20(=C2=A77.2,=20=C2=A77.4?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/tui/__snapshots__/patch-view.test.ts.snap | 55 ++++++++++++ src/tui/patch-menu.test.ts | 86 +++++++++++++++++++ src/tui/patch-view.test.ts | 9 ++ tui/patch.ts | 4 +- 4 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 src/tui/__snapshots__/patch-view.test.ts.snap create mode 100644 src/tui/patch-menu.test.ts diff --git a/src/tui/__snapshots__/patch-view.test.ts.snap b/src/tui/__snapshots__/patch-view.test.ts.snap new file mode 100644 index 00000000..bc04aa31 --- /dev/null +++ b/src/tui/__snapshots__/patch-view.test.ts.snap @@ -0,0 +1,55 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`paintPatch 80x24 frame (§7.2), unicode and ASCII 1`] = ` +" patch t ▸ acid-bass (instrument · 7 nodes · 10 cables · 1 voice) + NODES │ voice voice +▸voice ◆ │▸pitch → tone.pitch + tone ◆ osc saw │ note → · + env ◆ adsr │ gate → amp.gate, env.gate + envamt ◆ scale lin │ velocity → · + amp ◆ adsr │ phase → · + vcf ◆ svf lp │ random → · + vca ◆ │ index → · + drive ● fx.distort soft │ age → · + out ● │ +──────────────────────────────────────────────────────────────────────────────── + CABLES tone.pitch env.gate amp.gate + voice.pitch ▪ · · + voice.note · · · + voice.gate · ▪ ▪ + voice.velocity · · · + voice.phase · · · + voice.random · · · + voice.index · · · + voice.age · · · + ◆ voice ● global ~ audio ▪ control ♪ notes Σ voice sum ✕ dropped + ● Cutoff 600 ███── ▲ Reso 0.7 █████── ■ Env 2400 ███──── ◆ Drive 2.5 ██──── + tab pane · enter edit · a add · w wire · x remove · m knob · ? keys " +`; + +exports[`paintPatch 80x24 frame (§7.2), unicode and ASCII 2`] = ` +" patch t > acid-bass (instrument · 7 nodes · 10 cables · 1 voice) + NODES | voice voice +>voice v |>pitch -> tone.pitch + tone v osc saw | note -> - + env v adsr | gate -> amp.gate, env.gate + envamt v scale lin | velocity -> - + amp v adsr | phase -> - + vcf v svf lp | random -> - + vca v | index -> - + drive g fx.distort soft | age -> - + out g | +-------------------------------------------------------------------------------- + CABLES tone.pitch env.gate amp.gate + voice.pitch # . . + voice.note . . . + voice.gate . # # + voice.velocity . . . + voice.phase . . . + voice.random . . . + voice.index . . . + voice.age . . . + v voice g global ~ audio # control n notes S voice sum x dropped + o Cutoff 600 ===-- ^ Reso 0.7 =====-- # Env 2400 ===---- * Drive 2.5 ==---- + tab pane · enter edit · a add · w wire · x remove · m knob · ? keys " +`; diff --git a/src/tui/patch-menu.test.ts b/src/tui/patch-menu.test.ts new file mode 100644 index 00000000..2ee6508c --- /dev/null +++ b/src/tui/patch-menu.test.ts @@ -0,0 +1,86 @@ +/** + * The ctrl-k patch rows (patcher design §7.4) as a table: every row under + * Sound › patch and Effects › " patch", and the typed command it runs. + */ +import { describe, expect, test } from "bun:test"; +import { builtinPatch } from "../../core/patches/index.ts"; +import { createScore, type TrackScore } from "../../core/score.ts"; +import type { MenuContext, MenuNode } from "./menu.ts"; +import { effectPatchMenuNodes, patchMenuNode } from "./patch-menu.ts"; + +function song(track: Record): TrackScore { + return createScore({ + tempoBpm: 120, + bars: 2, + tracks: [{ id: "t", name: "t", ...track }], + notes: [], + } as Parameters[0]); +} + +function context(score: TrackScore): MenuContext { + return { + score, + trackId: "t", + playing: false, + grid: "1/16", + grids: ["1/16"], + clickOn: false, + countInBars: 1, + }; +} + +/** `path · command` for every row, menus walked depth first. */ +function table(ctx: MenuContext, nodes: MenuNode[], path = ""): string[] { + const rows: string[] = []; + for (const node of nodes) { + const here = path ? `${path} › ${node.label}` : node.label; + if (node.kind === "menu") rows.push(...table(ctx, node.build(ctx), here)); + else if (node.kind === "action") rows.push(`${here} · ${node.command}`); + else if (node.kind === "number" && node.value !== undefined) + rows.push(`${here} · ${node.command(node.value)}`); + else if (node.kind === "choice") + rows.push(`${here} · ${node.command(node.value)}`); + else if (node.kind === "toggle") + rows.push(`${here} · ${node.command(!node.value)}`); + else rows.push(`${here} · (${node.kind})`); + } + return rows; +} + +describe("ctrl-k patch rows", () => { + test("a patch track: edit, four knobs, every node's settings", () => { + const score = song({ + instrument: "patch", + patch: builtinPatch("acid-bass"), + }); + const ctx = context(score); + const rows = table(ctx, patchMenuNode(ctx, score.tracks[0])); + expect(rows[0]).toBe("patch › Edit patch · /patch"); + const knobs = rows.filter((row) => row.startsWith("patch › knobs › ")); + expect(knobs).toHaveLength(4); + for (const row of knobs) expect(row).toMatch(/ · patch knob \S+ [\d.-]+$/); + const nodes = rows.filter((row) => row.startsWith("patch › nodes › ")); + expect(nodes.length).toBeGreaterThan(0); + for (const row of nodes) expect(row).toMatch(/ · patch set \S+ \S+=\S+$/); + }); + + test("a track that is not a patch offers the preview only", () => { + const score = song({ instrument: "bass" }); + const ctx = context(score); + expect(table(ctx, patchMenuNode(ctx, score.tracks[0]))).toEqual([ + "patch › Edit patch · /patch", + ]); + }); + + test("an effect patch aims every row with --fx", () => { + const fx = builtinPatch("wide-crush")!; + const score = song({ instrument: "bass", fxPatch: [fx] }); + const ctx = context(score); + const rows = table(ctx, effectPatchMenuNodes(ctx, score.tracks[0])); + expect(rows[0]).toBe( + `${fx.name} patch › Edit effect patch · /patch --fx ${fx.name}`, + ); + expect(rows.length).toBeGreaterThan(4); + for (const row of rows) expect(row).toEndWith(` --fx ${fx.name}`); + }); +}); diff --git a/src/tui/patch-view.test.ts b/src/tui/patch-view.test.ts index a46ebd1d..54ee88ce 100644 --- a/src/tui/patch-view.test.ts +++ b/src/tui/patch-view.test.ts @@ -339,6 +339,15 @@ describe("paintPatch", () => { for (const line of lines) expect(line.length).toBeLessThanOrEqual(80); }); + test("80x24 frame (§7.2), unicode and ASCII", () => { + const m = model(acid()); + const state = patchViewState(); + expect(paint(80, 24, m, state).join("\n")).toMatchSnapshot(); + expect( + paint(80, 24, m, state, { theme: "mono", unicode: false }).join("\n"), + ).toMatchSnapshot(); + }); + test("a short body shows the matrix alone while it has focus", () => { const m = model(acid()); const state = patchViewState(); diff --git a/tui/patch.ts b/tui/patch.ts index fe0edf11..9e8d7d28 100644 --- a/tui/patch.ts +++ b/tui/patch.ts @@ -409,7 +409,9 @@ function paintLists( portsX + 2 + nameWidth, y, truncate( - port.kind === "param" ? port.value : `${mark} ${port.value || "·"}`, + port.kind === "param" + ? port.value + : `${mark} ${port.value || (unicode ? "·" : "-")}`, portsWidth - nameWidth - 3, ), port.kind === "param" ? roles.text : roles.faint, From 02ca6487eabcbc82cfda029b4fe0cabe8706d493 Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sat, 10 Oct 2026 16:53:15 -0400 Subject: [PATCH 5/6] patcher: patch menu table covers the lane 5 rows Co-Authored-By: Claude Opus 5.5 --- src/tui/patch-menu.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/tui/patch-menu.test.ts b/src/tui/patch-menu.test.ts index 2ee6508c..c11fdf47 100644 --- a/src/tui/patch-menu.test.ts +++ b/src/tui/patch-menu.test.ts @@ -54,7 +54,7 @@ describe("ctrl-k patch rows", () => { patch: builtinPatch("acid-bass"), }); const ctx = context(score); - const rows = table(ctx, patchMenuNode(ctx, score.tracks[0])); + const rows = table(ctx, [patchMenuNode(ctx)]); expect(rows[0]).toBe("patch › Edit patch · /patch"); const knobs = rows.filter((row) => row.startsWith("patch › knobs › ")); expect(knobs).toHaveLength(4); @@ -62,14 +62,17 @@ describe("ctrl-k patch rows", () => { const nodes = rows.filter((row) => row.startsWith("patch › nodes › ")); expect(nodes.length).toBeGreaterThan(0); for (const row of nodes) expect(row).toMatch(/ · patch set \S+ \S+=\S+$/); + // Then the typed-entry rows (lane 5): new, load, add node, wire, … + expect(rows).toContain("patch › show as text · patch show"); }); - test("a track that is not a patch offers the preview only", () => { + test("a track that is not a patch offers the preview and new/convert", () => { const score = song({ instrument: "bass" }); const ctx = context(score); - expect(table(ctx, patchMenuNode(ctx, score.tracks[0]))).toEqual([ - "patch › Edit patch · /patch", - ]); + const rows = table(ctx, [patchMenuNode(ctx)]); + expect(rows[0]).toBe("patch › Edit patch · /patch"); + expect(rows).toContain("patch › convert to patch · patch convert"); + expect(rows.some((row) => row.includes("knobs"))).toBe(false); }); test("an effect patch aims every row with --fx", () => { From 74b9958321ce1fb4238b14dbed236634a69b38da Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sat, 10 Oct 2026 16:53:50 -0400 Subject: [PATCH 6/6] patcher: every patch view key's command parses and applies with lane 5's grammar Co-Authored-By: Claude Opus 5.5 --- src/tui/patch-view.test.ts | 49 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/tui/patch-view.test.ts b/src/tui/patch-view.test.ts index 54ee88ce..5f6e9c59 100644 --- a/src/tui/patch-view.test.ts +++ b/src/tui/patch-view.test.ts @@ -10,6 +10,7 @@ import { CellBuffer } from "../../tui/screen.ts"; import { PATCH_NODES_MAX, paintPatch } from "../../tui/patch.ts"; import { getTheme } from "../../tui/theme.ts"; import { isStageable } from "./audition.ts"; +import { applyPatchCommand, parsePatchCommand } from "../commands/patch.ts"; import { focusCable, legalCable, @@ -432,3 +433,51 @@ describe("audition (§7 a, space)", () => { expect(isStageable("patch list")).toBe(false); }); }); + +describe("every key's command runs as typed (lane 5 grammar)", () => { + /** Each command a key produces, from each pane and node. */ + function emitted(m: PatchModel): string[] { + const out = new Set(); + const collect = (action: ReturnType) => { + if (action.type === "run") for (const c of action.commands) out.add(c); + if (action.type === "pick") + for (const i of action.items) out.add(i.value); + }; + for (let node = 0; node < m.nodes.length; node++) { + for (const pane of ["nodes", "ports", "knobs"] as const) { + for (const key of ["a", "w", "x", "m", "g", "\u001b[C", "\u001b[D"]) { + const state = patchViewState(); + state.node = node; + state.pane = pane; + collect(patchKey(state, m, key)); + } + } + } + const state = patchViewState(); + state.full = true; + state.pane = "matrix"; + const { rows, cols } = matrixAxes(m, state); + for (let row = 0; row < rows.length; row++) + for (let col = 0; col < cols.length; col++) + for (const key of ["\r", "]", "5"]) { + state.row = row; + state.col = col; + collect(patchKey(state, m, key)); + } + return [...out]; + } + + test("each parses and applies to the patch it was made from", () => { + const score = acid(); + const commands = emitted(model(score)); + expect(commands.length).toBeGreaterThan(40); + for (const verb of ["add", "wire", "unwire", "rm", "knob", "macro"]) + expect(commands.some((c) => c.startsWith(`patch ${verb} `))).toBe(true); + for (const line of commands) { + const parsed = parsePatchCommand(line); + expect(parsed, line).toBeDefined(); + const result = applyPatchCommand(score, "t", parsed!); + expect(result.ok ? "" : `${line}: ${result.message}`).toBe(""); + } + }); +});