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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions DAWG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` opens one of its effect patches (Effects › "<name> 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.

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`.
Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion guides/panes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/commands/grammar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ export const WINDOW_VERBS: ReadonlySet<string> = new Set([
"fitmode",
"euclid",
"tape",
"patch",
"style",
"styles",
"calibration",
Expand Down
1 change: 1 addition & 0 deletions src/commands/help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ describe("help reference", () => {
"/menu",
"/model",
"/motion",
"/patch",
"/play",
"/rename",
"/resume",
Expand Down
7 changes: 6 additions & 1 deletion src/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>] [off]",
summary:
"the patch view: nodes, ports, cables · a add · w wire · m knob",
},
],
},
{
Expand Down Expand Up @@ -1094,7 +1099,7 @@ export const USAGE: Readonly<Record<string, string>> = {
reverse: "reverse [<track>|all] [<a>-<b>|<section>] · reverse bass 5-6",
jump: "jump <bar>[.<beat>] | <section> · jump 5 · jump 5.3 · jump chorus",
loop: "loop <a>-<b> | <section> | next | prev | off · loop 5-6 · loop chorus · loop off",
pane: "pane home|play|tape|sound|menu [<track>] [pin|follow [<letter>]] · pane tape · pane sound bass pin",
pane: "pane home|play|tape|sound|menu|patch [<track>] [pin|follow [<letter>]] · pane tape · pane sound bass pin · pane patch bass",
follow: "follow [<letter>] · follow b · unfollow stops",
panes: "panes · who has this session open, on what · pane tape",
knobs:
Expand Down
10 changes: 10 additions & 0 deletions src/launch-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
});
Expand Down
12 changes: 10 additions & 2 deletions src/launch-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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!);
Expand Down
188 changes: 179 additions & 9 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,19 @@ import {
type TapeContext,
} from "./tui/tape-mode.ts";
import { tapeView } from "./tui/tape-view.ts";
import {
clampState,
focusCable,
newCables,
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 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";
Expand Down Expand Up @@ -868,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 <sound command> · /try fx reverb mix 0.6 · /try agent on|off";
/** Whether the agent's preview_sound plays its snippet in this window. */
Expand All @@ -885,6 +898,20 @@ 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;
/** 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(),
};
/**
* 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
Expand Down Expand Up @@ -1208,7 +1235,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),
};
}
Expand Down Expand Up @@ -2112,6 +2143,10 @@ async function runInteractive(): Promise<void> {
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());
Expand Down Expand Up @@ -2396,6 +2431,14 @@ async function submit(prompt: string): Promise<string | Receipt> {
`${lines.length} note${lines.length === 1 ? "" : "s"} · ${requestedTrack} · remove <id>`,
);
}
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();
Expand Down Expand Up @@ -4435,13 +4478,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;
Expand Down Expand Up @@ -4470,6 +4515,7 @@ async function openPane(pane: PaneArgs): Promise<void> {
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")
Expand Down Expand Up @@ -4725,6 +4771,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;
}
Expand Down Expand Up @@ -5729,6 +5776,7 @@ function echoGesture(commands: readonly string[]): void {
async function enterTape(): Promise<Receipt> {
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;
Expand All @@ -5741,6 +5789,128 @@ async function enterTape(): Promise<Receipt> {
);
}

/** `/patch [--fx <name>]`: the patch view on the focused track. */
async function enterPatch(fx?: string): Promise<Receipt> {
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.cables = undefined;
}
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;
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);
}

/**
* 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} <param>=<value>`, {
tone: "info",
});
}
return true;
}
case "pick":
tui.openPicker({
id: action.id,
title: action.title,
// 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,
})),
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 "audition":
void auditionKeyPressed("loop");
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;
Expand Down
Loading
Loading