@hraness/dawg 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/DAWG.md +117 -30
  3. package/README.md +7 -5
  4. package/core/diff.ts +2 -0
  5. package/core/ids.ts +52 -0
  6. package/core/loop.ts +3 -0
  7. package/core/range.ts +581 -0
  8. package/core/score.ts +123 -1
  9. package/core/sdk/print.ts +11 -2
  10. package/core/sdk/v1.ts +281 -2
  11. package/core/sections.ts +198 -19
  12. package/guides/arrange.md +9 -8
  13. package/guides/audio.md +31 -0
  14. package/guides/mix.md +3 -1
  15. package/guides/panes.md +32 -0
  16. package/guides/project.md +3 -2
  17. package/guides/rhythm.md +2 -1
  18. package/guides/tape.md +32 -0
  19. package/package.json +2 -1
  20. package/src/agent/agent.ts +4 -4
  21. package/src/agent/command-agent.ts +19 -10
  22. package/src/agent/ops.ts +15 -0
  23. package/src/agent/planner.ts +12 -0
  24. package/src/agent/range-tools.ts +216 -0
  25. package/src/agent/show-me.ts +1 -1
  26. package/src/agent/tools.ts +2 -0
  27. package/src/agent/xcb-agent.ts +2 -3
  28. package/src/audio/arrange.ts +12 -2
  29. package/src/audio/audio-command.ts +218 -0
  30. package/src/audio/devices.ts +264 -0
  31. package/src/audio/doctor.ts +84 -13
  32. package/src/audio/engine.ts +216 -28
  33. package/src/commands/arrange.ts +60 -25
  34. package/src/commands/grammar.ts +3 -1
  35. package/src/commands/help.ts +187 -9
  36. package/src/commands/history.ts +122 -11
  37. package/src/commands/keys.ts +17 -0
  38. package/src/commands/parses.ts +6 -1
  39. package/src/commands/range.ts +763 -0
  40. package/src/commands/resample.ts +13 -10
  41. package/src/commands/sample.ts +11 -1
  42. package/src/commands/sing.ts +2 -6
  43. package/src/commands/vocal-pitch.ts +2 -1
  44. package/src/identity/actor.ts +127 -0
  45. package/src/lang/glossary.ts +18 -0
  46. package/src/launch-args.ts +105 -1
  47. package/src/main.ts +1142 -86
  48. package/src/session/client.ts +107 -3
  49. package/src/session/clipboard.ts +79 -0
  50. package/src/session/daemon.ts +196 -5
  51. package/src/session/live-host.ts +232 -0
  52. package/src/session/origin.ts +20 -0
  53. package/src/session/port.ts +87 -10
  54. package/src/session/protocol.ts +360 -6
  55. package/src/session/rebase.ts +46 -8
  56. package/src/session/receipt.ts +7 -0
  57. package/src/session/shared-live.ts +131 -0
  58. package/src/session/store.ts +61 -2
  59. package/src/tui/arrange-menu.ts +169 -1
  60. package/src/tui/euclid.ts +95 -14
  61. package/src/tui/fader.ts +55 -2
  62. package/src/tui/knob-fields.ts +107 -0
  63. package/src/tui/knob-map.ts +198 -0
  64. package/src/tui/menu.ts +228 -2
  65. package/src/tui/play-session.ts +196 -21
  66. package/src/tui/tape-mode.ts +580 -0
  67. package/src/tui/tape-view.ts +263 -0
  68. package/tui/app.ts +217 -44
  69. package/tui/delight.ts +49 -0
  70. package/tui/drawer.ts +167 -9
  71. package/tui/grammar.ts +34 -5
  72. package/tui/guide.ts +8 -1
  73. package/tui/highway.ts +40 -5
  74. package/tui/hints.ts +5 -0
  75. package/tui/hits.ts +7 -1
  76. package/tui/knobs.ts +268 -0
  77. package/tui/play-strip.ts +12 -0
  78. package/tui/screen.ts +7 -4
  79. package/tui/tape.ts +439 -0
  80. package/tui/text.ts +35 -2
  81. package/tui/theme.ts +19 -1
@@ -8,6 +8,7 @@
8
8
  * track (the `cloud` preset) holding one note across the range. The source
9
9
  * stays as it is; mute it to hear only the resample.
10
10
  */
11
+ import { newId } from "../../core/ids.ts";
11
12
  import { mkdir, realpath, rename, writeFile } from "node:fs/promises";
12
13
  import { join } from "node:path";
13
14
  import { MAX_ORBIT } from "../../core/fx.ts";
@@ -18,6 +19,7 @@ import {
18
19
  TrackScore,
19
20
  addTrack,
20
21
  type SampleRef,
22
+ requirePinnedRef,
21
23
  type TrackInput,
22
24
  } from "../../core/score.ts";
23
25
  import { trackDirectories } from "../../core/sdk/print.ts";
@@ -155,13 +157,16 @@ export function addResampleTrack(
155
157
  trackId: string,
156
158
  src: string,
157
159
  ): TrackScore {
158
- const ref: SampleRef = {
159
- src,
160
- sha256: render.sha256,
161
- from: render.from,
162
- // A bounce starts at full level: no anti-click fade-in.
163
- ...(command.grain ? {} : { gain: RESAMPLE_GAIN, fadeInTime: 0 }),
164
- };
160
+ const ref: SampleRef = requirePinnedRef(
161
+ {
162
+ src,
163
+ sha256: render.sha256,
164
+ from: render.from,
165
+ // A bounce starts at full level: no anti-click fade-in.
166
+ ...(command.grain ? {} : { gain: RESAMPLE_GAIN, fadeInTime: 0 }),
167
+ },
168
+ "resample",
169
+ );
165
170
  const track: TrackInput = command.grain
166
171
  ? {
167
172
  id: trackId,
@@ -183,15 +188,13 @@ export function addResampleTrack(
183
188
  // plays the source at its own pitch.
184
189
  const pitch = command.grain ? 60 : 36;
185
190
  const notes = next.notes;
186
- let n = 1;
187
- while (notes.some((note) => note.id === `${trackId}-${n}`)) n += 1;
188
191
  const ticks = Math.min(render.durationTicks, next.bars * barTicks(next));
189
192
  next = new TrackScore({
190
193
  ...next.toJSON(),
191
194
  notes: [
192
195
  ...notes,
193
196
  {
194
- id: `${trackId}-${n}`,
197
+ id: newId(trackId),
195
198
  trackId,
196
199
  startTick: render.startTick,
197
200
  durationTicks: Math.max(1, ticks),
@@ -24,6 +24,9 @@ import {
24
24
  samplerVoiceSlots,
25
25
  type Sampler,
26
26
  type SampleRef,
27
+ type PinnedSampleRef,
28
+ requirePinnedRef,
29
+ ScoreValidationError,
27
30
  type Track,
28
31
  } from "../../core/score.ts";
29
32
  import { trackDirectories } from "../../core/sdk/print.ts";
@@ -317,8 +320,15 @@ export function addSampleVoice(
317
320
  score: TrackScore,
318
321
  trackId: string,
319
322
  voice: string,
320
- ref: SampleRef,
323
+ ref: PinnedSampleRef,
321
324
  ): AddVoiceResult {
325
+ try {
326
+ requirePinnedRef(ref, `sample ${voice}`);
327
+ } catch (error) {
328
+ if (error instanceof ScoreValidationError)
329
+ return { ok: false, message: `sample · ${error.message}` };
330
+ throw error;
331
+ }
322
332
  if (!VOICE.test(voice))
323
333
  return {
324
334
  ok: false,
@@ -19,6 +19,7 @@
19
19
  * Any edit makes the track a sing track (`instrument "sing"` plus a `sing`
20
20
  * field). One `updateTrack` revision and one undo step per command.
21
21
  */
22
+ import { newId } from "../../core/ids.ts";
22
23
  import { isGuideInstrument } from "../../core/clips.ts";
23
24
  import { parseKey } from "../../core/chords.ts";
24
25
  import { FxValidationError } from "../../core/params.ts";
@@ -330,18 +331,13 @@ export function throatDemo(
330
331
  if (drone === undefined) return undefined;
331
332
  const step = score.ticksPerBeat * 2;
332
333
  const total = score.ticksPerBeat * score.beatsPerBar * score.bars;
333
- const ids = new Set(score.notes.map((note) => note.id));
334
334
  let next = score;
335
335
  let count = 0;
336
336
  for (const [index, offset] of THROAT_DEMO_STEPS.entries()) {
337
337
  const startTick = index * step;
338
338
  if (startTick + step > total) break;
339
- let id = `${trackId}-demo-${index + 1}`;
340
- for (let n = 2; ids.has(id); n += 1)
341
- id = `${trackId}-demo-${index + 1}-${n}`;
342
- ids.add(id);
343
339
  next = addNote(next, {
344
- id,
340
+ id: newId(`${trackId}-demo`),
345
341
  trackId,
346
342
  startTick,
347
343
  durationTicks: step,
@@ -8,6 +8,7 @@
8
8
  * through `core/key.ts`, the same estimator `/media analyze` uses), so a
9
9
  * long held tonic weighs more than a passing tone.
10
10
  */
11
+ import { newId } from "../../core/ids.ts";
11
12
  import {
12
13
  TrackScore,
13
14
  SCORE_LIMITS,
@@ -338,7 +339,7 @@ export function guideNotesScore(
338
339
  );
339
340
  if (startTick < 0 || startTick >= end) continue;
340
341
  notes.push({
341
- id: `${trackId}-${notes.length + 1}`,
342
+ id: newId(trackId),
342
343
  trackId,
343
344
  startTick,
344
345
  durationTicks: Math.max(1, endTick - startTick),
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Actor identity placeholder: who made an edit, stable across windows and
3
+ * restarts on one machine. No keys and no login; a later signed identity can
4
+ * bind to this id. It is sent only on the session protocol (`hello`), never
5
+ * to model gateways or anywhere else.
6
+ *
7
+ * File: `<config>/actor.json` = `{ "id": "a_<22 base32>", "name": "$USER" }`,
8
+ * created once with an exclusive atomic write so concurrent first runs agree.
9
+ */
10
+ import { link, mkdir, readFile, rm, writeFile } from "node:fs/promises";
11
+ import { randomUUID } from "node:crypto";
12
+ import { userInfo } from "node:os";
13
+ import { join } from "node:path";
14
+ import { configDir } from "../auth/credentials.ts";
15
+ import { randomBase32, setIdSalt } from "../../core/ids.ts";
16
+
17
+ export type Actor = Readonly<{ id: string; name: string }>;
18
+
19
+ export const ACTOR_ID = /^a_[a-z2-7]{22}$/;
20
+ const MAX_NAME_LENGTH = 64;
21
+
22
+ export function isActorId(value: unknown): value is string {
23
+ return typeof value === "string" && ACTOR_ID.test(value);
24
+ }
25
+
26
+ export function actorPath(dir: string = configDir()): string {
27
+ return join(dir, "actor.json");
28
+ }
29
+
30
+ function defaultName(env: Readonly<Record<string, string | undefined>>) {
31
+ let name = env.USER || env.LOGNAME || "";
32
+ if (!name) {
33
+ try {
34
+ name = userInfo().username;
35
+ } catch {
36
+ name = "";
37
+ }
38
+ }
39
+ return cleanName(name) || "me";
40
+ }
41
+
42
+ function cleanName(value: unknown): string {
43
+ if (typeof value !== "string") return "";
44
+ return value
45
+ .replace(/[\u0000-\u001f\u007f]/g, "")
46
+ .trim()
47
+ .slice(0, MAX_NAME_LENGTH);
48
+ }
49
+
50
+ function parseActor(text: string): Actor | undefined {
51
+ try {
52
+ const value = JSON.parse(text) as Record<string, unknown>;
53
+ if (typeof value !== "object" || value === null) return undefined;
54
+ if (!isActorId(value.id)) return undefined;
55
+ return { id: value.id, name: cleanName(value.name) || "me" };
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ let cached: Actor | undefined;
62
+
63
+ /** A process-local actor that is never written (tests, read-only configs). */
64
+ export function ephemeralActor(name = "me"): Actor {
65
+ return { id: `a_${randomBase32(22)}`, name: cleanName(name) || "me" };
66
+ }
67
+
68
+ /**
69
+ * Reads the actor file, creating it on first run. A corrupt file is replaced
70
+ * (the old id is unrecoverable anyway). Never throws: an unwritable config
71
+ * dir yields an ephemeral actor for this process.
72
+ */
73
+ export async function loadActor(
74
+ options: {
75
+ dir?: string;
76
+ env?: Readonly<Record<string, string | undefined>>;
77
+ } = {},
78
+ ): Promise<Actor> {
79
+ const env = options.env ?? process.env;
80
+ const dir = options.dir ?? configDir(env);
81
+ const path = actorPath(dir);
82
+ const existing = await readFile(path, "utf8").then(
83
+ parseActor,
84
+ () => undefined,
85
+ );
86
+ if (existing) return existing;
87
+ const fresh = ephemeralActor(defaultName(env));
88
+ try {
89
+ await mkdir(dir, { recursive: true, mode: 0o700 });
90
+ const temp = `${path}.${randomUUID()}.tmp`;
91
+ await writeFile(temp, `${JSON.stringify(fresh)}\n`, { mode: 0o600 });
92
+ try {
93
+ // link() fails if the file exists: the first writer wins, others adopt it.
94
+ await link(temp, path);
95
+ } catch (error) {
96
+ const code = (error as { code?: string }).code;
97
+ if (code !== "EEXIST") throw error;
98
+ const winner = await readFile(path, "utf8").then(
99
+ parseActor,
100
+ () => undefined,
101
+ );
102
+ if (winner) return winner;
103
+ // Corrupt file: replace it.
104
+ await rm(path, { force: true });
105
+ await link(temp, path);
106
+ } finally {
107
+ await rm(temp, { force: true });
108
+ }
109
+ return fresh;
110
+ } catch {
111
+ return fresh;
112
+ }
113
+ }
114
+
115
+ /** Process-wide actor, loaded once; also salts `newId`. */
116
+ export async function currentActor(): Promise<Actor> {
117
+ if (!cached) {
118
+ cached = await loadActor();
119
+ setIdSalt(cached.id);
120
+ }
121
+ return cached;
122
+ }
123
+
124
+ /** Tests only. */
125
+ export function resetActorCache(): void {
126
+ cached = undefined;
127
+ }
@@ -398,6 +398,24 @@ export const TERMS: readonly Term[] = [
398
398
  losers: ["STEER", "QUEUE"],
399
399
  aliases: [],
400
400
  },
401
+ {
402
+ term: "range",
403
+ meaning: "bars a to b on one track or all (copy bass 5-6 to 7)",
404
+ losers: [],
405
+ aliases: [],
406
+ },
407
+ {
408
+ term: "clipboard",
409
+ meaning: "what copy without `to` holds until paste (tape c, x, v)",
410
+ losers: [],
411
+ aliases: [],
412
+ },
413
+ {
414
+ term: "pane",
415
+ meaning: "one terminal on a shared session, with a letter and own undo",
416
+ losers: [],
417
+ aliases: [],
418
+ },
401
419
  ];
402
420
 
403
421
  /** British spellings dawg writes the American way (lowercase keys). */
@@ -40,8 +40,27 @@ export const BOOLEAN_FLAGS: readonly string[] = [
40
40
  /** A `--track` value resolved to the id `/track` would use. */
41
41
  export type TrackArg = { id: string; name: string };
42
42
 
43
+ /** Screens a pane can open on (§12.5). */
44
+ export const PANE_SCREENS = ["home", "play", "tape", "sound", "menu"] as const;
45
+ export type PaneScreen = (typeof PANE_SCREENS)[number];
46
+
47
+ /**
48
+ * `dawg pane <screen> [track] [param] [pin | follow [letter]]`: what this
49
+ * terminal shows of the session in the current directory. `sound` opens
50
+ * the drawer on `param` (default volume); `menu` opens `param` as a menu
51
+ * section. `follow` tracks another pane's focus (`*` = the latest).
52
+ */
53
+ export type PaneArgs = {
54
+ screen: PaneScreen;
55
+ param: string | undefined;
56
+ pin: boolean;
57
+ follow: string | undefined;
58
+ };
59
+
43
60
  export type LaunchArgs = {
44
61
  subcommand: string | undefined;
62
+ /** `dawg pane …` (its track is `track`). */
63
+ pane?: PaneArgs;
45
64
  flags: ReadonlySet<string>;
46
65
  session: string | undefined;
47
66
  track: TrackArg | undefined;
@@ -90,6 +109,7 @@ export function parseLaunchArgs(argv: readonly string[]): LaunchParse {
90
109
  const flags = new Set<string>();
91
110
  const values = new Map<ValueFlag, string>();
92
111
  const first = argv[0];
112
+ if (first === "pane") return parsePaneArgs(argv.slice(1));
93
113
  if (first !== undefined && !first.startsWith("-")) {
94
114
  if (SUBCOMMANDS.includes(first))
95
115
  return { ok: true, args: emptyArgs(first, flags) };
@@ -146,7 +166,91 @@ export function parseLaunchArgs(argv: readonly string[]): LaunchParse {
146
166
  };
147
167
  }
148
168
 
149
- function emptyArgs(subcommand: string, flags: Set<string>): LaunchArgs {
169
+ /** Drawer parameters `dawg pane sound <word>` opens (else the word is a track). */
170
+ const SOUND_PARAMS: readonly string[] = [
171
+ "volume",
172
+ "pan",
173
+ "tempo",
174
+ "bpm",
175
+ "bars",
176
+ "meter",
177
+ ];
178
+
179
+ const PANE_USAGE =
180
+ "usage: dawg pane home|play|tape|sound|menu [track] [volume|pan|section] [pin|follow [A-Z]]";
181
+
182
+ export function parsePaneArgs(argv: readonly string[]): LaunchParse {
183
+ const [screenWord, ...rest] = argv;
184
+ const screen = PANE_SCREENS.find(
185
+ (name) => name === screenWord?.toLowerCase(),
186
+ );
187
+ if (!screen)
188
+ return {
189
+ ok: false,
190
+ problem: screenWord
191
+ ? `unknown pane screen ${clip(screenWord)} · ${PANE_USAGE}`
192
+ : PANE_USAGE,
193
+ };
194
+ let pin = false;
195
+ let follow: string | undefined;
196
+ const words: string[] = [];
197
+ for (let index = 0; index < rest.length; index += 1) {
198
+ const word = rest[index]!;
199
+ if (word === "pin") pin = true;
200
+ else if (word === "follow") {
201
+ const letter = rest[index + 1];
202
+ if (letter !== undefined && /^[A-Z]$/i.test(letter)) {
203
+ follow = letter.toUpperCase();
204
+ index += 1;
205
+ } else follow = "*";
206
+ } else if (word.startsWith("-"))
207
+ return {
208
+ ok: false,
209
+ problem: `unknown option · ${clip(word)} · ${PANE_USAGE}`,
210
+ };
211
+ else words.push(word);
212
+ }
213
+ if (pin && follow)
214
+ return { ok: false, problem: `pin or follow, not both · ${PANE_USAGE}` };
215
+ if (words.length > 2)
216
+ return { ok: false, problem: `too many words · ${PANE_USAGE}` };
217
+ let track: TrackArg | undefined;
218
+ // `menu mix`, `sound filter`: one word after these is the section or the
219
+ // parameter; a track then comes first (`sound bass filter`).
220
+ const [first, second] = words;
221
+ let param: string | undefined;
222
+ const trackFirst =
223
+ words.length === 2 ||
224
+ (first !== undefined &&
225
+ (screen === "home" ||
226
+ screen === "play" ||
227
+ (screen === "sound" && !SOUND_PARAMS.includes(first.toLowerCase()))));
228
+ if (trackFirst) {
229
+ track = normalizeTrackArg(first!);
230
+ if (!track) return { ok: false, problem: "invalid track name" };
231
+ param = second;
232
+ } else param = first;
233
+ if (param !== undefined && !/^[a-z][a-z0-9 ._-]{0,63}$/i.test(param))
234
+ return { ok: false, problem: `invalid pane parameter · ${PANE_USAGE}` };
235
+ if (param !== undefined && (screen === "home" || screen === "play"))
236
+ return {
237
+ ok: false,
238
+ problem: `${screen} takes a track only · ${PANE_USAGE}`,
239
+ };
240
+ return {
241
+ ok: true,
242
+ args: {
243
+ ...emptyArgs(undefined, new Set()),
244
+ track,
245
+ pane: { screen, param: param?.toLowerCase(), pin, follow },
246
+ },
247
+ };
248
+ }
249
+
250
+ function emptyArgs(
251
+ subcommand: string | undefined,
252
+ flags: Set<string>,
253
+ ): LaunchArgs {
150
254
  return {
151
255
  subcommand,
152
256
  flags,