@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
package/core/sections.ts CHANGED
@@ -535,9 +535,9 @@ export function sectionScore(score: TrackScore, section: Section): TrackScore {
535
535
  // ---------------------------------------------------------------------------
536
536
  // Automation over time edits
537
537
 
538
- type Points = readonly AutomationPoint[];
538
+ export type Points = readonly AutomationPoint[];
539
539
 
540
- const TRACK_LANES = [
540
+ export const TRACK_LANES = [
541
541
  "volumeAutomation",
542
542
  "panAutomation",
543
543
  "filterAutomation",
@@ -548,7 +548,10 @@ const TRACK_LANES = [
548
548
  ] as const;
549
549
 
550
550
  /** Applies `edit` to every automation lane of `track`. */
551
- function mapAutomation(track: Track, edit: (points: Points) => Points): Track {
551
+ export function mapAutomation(
552
+ track: Track,
553
+ edit: (points: Points) => Points,
554
+ ): Track {
552
555
  const out: Record<string, unknown> = { ...track };
553
556
  let changed = false;
554
557
  for (const field of TRACK_LANES) {
@@ -578,7 +581,7 @@ function mapAutomation(track: Track, edit: (points: Points) => Points): Track {
578
581
  }
579
582
 
580
583
  /** The lane value at `tick` (undefined before the first point). */
581
- function valueAt(points: Points, tick: number): number | undefined {
584
+ export function valueAt(points: Points, tick: number): number | undefined {
582
585
  const first = points[0];
583
586
  if (!first || tick < first.tick) return undefined;
584
587
  for (let index = 1; index < points.length; index += 1) {
@@ -593,7 +596,7 @@ function valueAt(points: Points, tick: number): number | undefined {
593
596
  return points[points.length - 1]!.value;
594
597
  }
595
598
 
596
- function sortPoints(points: AutomationPoint[]): Points {
599
+ export function sortPoints(points: AutomationPoint[]): Points {
597
600
  const byTick = new Map<number, AutomationPoint>();
598
601
  for (const point of points) byTick.set(point.tick, point);
599
602
  return [...byTick.values()].sort((a, b) => a.tick - b.tick);
@@ -682,7 +685,7 @@ function copyPoints(
682
685
  // ---------------------------------------------------------------------------
683
686
  // Ripple edits
684
687
 
685
- function checkBars(bars: number): void {
688
+ export function checkBars(bars: number): void {
686
689
  if (bars > SCORE_LIMITS.maxBars)
687
690
  throw new ScoreValidationError(
688
691
  `the song would be ${bars} bars; the limit is ${SCORE_LIMITS.maxBars}`,
@@ -806,6 +809,14 @@ export function insertBars(
806
809
  ? { ...section, bars: section.bars + count }
807
810
  : section,
808
811
  ),
812
+ // The loop range moves (or grows) like a section.
813
+ loop: score.loop
814
+ ? score.loop.startBar >= atBar
815
+ ? { ...score.loop, startBar: score.loop.startBar + count }
816
+ : score.loop.startBar + score.loop.bars > atBar
817
+ ? { ...score.loop, bars: score.loop.bars + count }
818
+ : score.loop
819
+ : null,
809
820
  });
810
821
  }
811
822
 
@@ -884,9 +895,27 @@ export function deleteBars(
884
895
  notes,
885
896
  sections,
886
897
  form: score.form.filter((entry) => names.has(foldName(entry.section))),
898
+ loop: score.loop ? shrinkRange(score.loop, atBar, count) : null,
887
899
  });
888
900
  }
889
901
 
902
+ /** A bar range after bars `atBar..atBar+count` go (null when it goes too). */
903
+ function shrinkRange(
904
+ range: Readonly<{ startBar: number; bars: number }>,
905
+ atBar: number,
906
+ count: number,
907
+ ): { startBar: number; bars: number } | null {
908
+ const until = range.startBar + range.bars;
909
+ if (until <= atBar) return range;
910
+ if (range.startBar >= atBar + count)
911
+ return { startBar: range.startBar - count, bars: range.bars };
912
+ const left = Math.max(0, atBar - range.startBar);
913
+ const right = Math.max(0, until - (atBar + count));
914
+ return left + right > 0
915
+ ? { startBar: Math.min(range.startBar, atBar), bars: left + right }
916
+ : null;
917
+ }
918
+
890
919
  /** Unused note ids `prefix1`, `prefix2`, ... */
891
920
  function idMaker(score: TrackScore, prefix: string): () => string {
892
921
  const used = new Set(score.notes.map((note) => note.id));
@@ -1088,24 +1117,49 @@ export function duplicateSection(
1088
1117
  );
1089
1118
  }
1090
1119
 
1091
- /** Copy the music of bars `from..from+count` onto bars `to..` (replacing it). */
1120
+ /** Options for `copyBars`: which tracks, and whether the copy overdubs. */
1121
+ export type CopyBarsOptions = Readonly<{
1122
+ /** Only these tracks (default all). A track subset leaves tempo alone. */
1123
+ trackIds?: ReadonlySet<string>;
1124
+ /** Overdub: keep the destination's notes (default replaces them). */
1125
+ merge?: boolean;
1126
+ }>;
1127
+
1128
+ /**
1129
+ * Copy the music of bars `from..from+count` onto bars `to..` (replacing it,
1130
+ * or with `merge` overdubbing it). All tracks bring the song's tempo and
1131
+ * fermatas with them; a track subset copies only its own notes, clips,
1132
+ * pedal and automation.
1133
+ */
1092
1134
  export function copyBars(
1093
1135
  score: TrackScore,
1094
1136
  fromBar: number,
1095
1137
  count: number,
1096
1138
  toBar: number,
1097
1139
  idPrefix = "copy-",
1140
+ options: CopyBarsOptions = {},
1098
1141
  ): TrackScore {
1099
1142
  const ticks = barTicks(score);
1100
1143
  const from = fromBar * ticks;
1101
1144
  const length = count * ticks;
1102
1145
  const to = toBar * ticks;
1103
1146
  const nextId = idMaker(score, idPrefix);
1147
+ const picked = (trackId: string): boolean =>
1148
+ options.trackIds === undefined || options.trackIds.has(trackId);
1104
1149
  const kept = score.notes.filter(
1105
- (note) => note.startTick < to || note.startTick >= to + length,
1150
+ (note) =>
1151
+ options.merge ||
1152
+ !picked(note.trackId) ||
1153
+ note.startTick < to ||
1154
+ note.startTick >= to + length,
1106
1155
  );
1107
1156
  const copies = score.notes
1108
- .filter((note) => note.startTick >= from && note.startTick < from + length)
1157
+ .filter(
1158
+ (note) =>
1159
+ picked(note.trackId) &&
1160
+ note.startTick >= from &&
1161
+ note.startTick < from + length,
1162
+ )
1109
1163
  .map((note) => ({
1110
1164
  ...note,
1111
1165
  id: nextId(),
@@ -1122,19 +1176,21 @@ export function copyBars(
1122
1176
  { from, to: from + length, offset: to },
1123
1177
  { from: to + length, to: Infinity, offset: to + length },
1124
1178
  ]);
1179
+ const song = options.trackIds === undefined;
1125
1180
  return new TrackScore({
1126
1181
  ...score.toJSON(),
1127
- tempoBpm: ripple.tempoBpm,
1128
- time: ripple.time,
1182
+ ...(song ? { tempoBpm: ripple.tempoBpm, time: ripple.time } : {}),
1129
1183
  tracks: score.tracks.map((track) =>
1130
- copyClipBars(
1131
- mapAutomation(ripple.pedals(track), (points) =>
1132
- copyPoints(points, from, length, to),
1133
- ),
1134
- from,
1135
- length,
1136
- to,
1137
- ),
1184
+ picked(track.id)
1185
+ ? copyClipBars(
1186
+ mapAutomation(ripple.pedals(track), (points) =>
1187
+ copyPoints(points, from, length, to),
1188
+ ),
1189
+ from,
1190
+ length,
1191
+ to,
1192
+ )
1193
+ : track,
1138
1194
  ),
1139
1195
  notes: [...kept, ...copies],
1140
1196
  });
@@ -1250,6 +1306,129 @@ export function resetSection(score: TrackScore, name: string): TrackScore {
1250
1306
  });
1251
1307
  }
1252
1308
 
1309
+ /**
1310
+ * Split a section at `atBar` (0-based, strictly inside it) into two: the
1311
+ * first keeps the name, the second is `<name> 2` (or `as`). Both keep the
1312
+ * mutes and variations; the form plays both halves wherever it played the
1313
+ * section, and a loop on it becomes the loop range over the same bars.
1314
+ */
1315
+ export function splitSection(
1316
+ score: TrackScore,
1317
+ name: string,
1318
+ atBar: number,
1319
+ as?: string,
1320
+ ): TrackScore {
1321
+ const section = requireSection(score, name);
1322
+ const end = section.startBar + section.bars;
1323
+ if (!Number.isInteger(atBar) || atBar <= section.startBar || atBar >= end)
1324
+ throw new ScoreValidationError(
1325
+ section.bars < 2
1326
+ ? `${section.name} is one bar; nothing to split`
1327
+ : `split ${section.name} at a bar ${section.startBar + 2}..${end}`,
1328
+ );
1329
+ if (as !== undefined && findSection(score, as))
1330
+ throw new ScoreValidationError(`a section named ${as} exists`);
1331
+ const second = uniqueSectionName(score, as ?? `${section.name} 2`);
1332
+ const form: FormEntry[] = [];
1333
+ for (const entry of score.form) {
1334
+ if (foldName(entry.section) !== foldName(section.name)) {
1335
+ form.push(entry);
1336
+ continue;
1337
+ }
1338
+ for (let pass = 0; pass < (entry.repeat ?? 1); pass += 1)
1339
+ form.push({ section: section.name }, { section: second });
1340
+ }
1341
+ if (form.length > SCORE_LIMITS.maxFormEntries)
1342
+ throw new ScoreValidationError(
1343
+ `the form would have ${form.length} entries; the limit is ${SCORE_LIMITS.maxFormEntries}`,
1344
+ "score-limit",
1345
+ );
1346
+ const looping = score.loopSection === section.name;
1347
+ const split = score.withSections(
1348
+ score.sections.flatMap((candidate) =>
1349
+ candidate === section
1350
+ ? [
1351
+ { ...section, bars: atBar - section.startBar },
1352
+ { ...section, name: second, startBar: atBar, bars: end - atBar },
1353
+ ]
1354
+ : [candidate],
1355
+ ),
1356
+ form,
1357
+ looping ? null : score.loopSection,
1358
+ );
1359
+ return looping
1360
+ ? split.withLoop({ startBar: section.startBar, bars: section.bars })
1361
+ : split;
1362
+ }
1363
+
1364
+ /**
1365
+ * Join a section with the one that starts where it ends; it keeps the
1366
+ * first one's name, mutes and variations. Refused when the two mute or vary
1367
+ * differently (joining would change the sound) unless `force`, or when
1368
+ * the form plays the second anywhere but right after the first.
1369
+ */
1370
+ export function joinSection(
1371
+ score: TrackScore,
1372
+ name: string,
1373
+ options: Readonly<{ force?: boolean }> = {},
1374
+ ): TrackScore {
1375
+ const section = requireSection(score, name);
1376
+ const end = section.startBar + section.bars;
1377
+ const next = score.sections.find((candidate) => candidate.startBar === end);
1378
+ if (!next)
1379
+ throw new ScoreValidationError(
1380
+ `no section starts at bar ${end + 1}, right after ${section.name}`,
1381
+ );
1382
+ const sameMute =
1383
+ JSON.stringify([...(section.mute ?? [])].sort()) ===
1384
+ JSON.stringify([...(next.mute ?? [])].sort());
1385
+ if (
1386
+ !options.force &&
1387
+ (!sameMute ||
1388
+ JSON.stringify(section.vary ?? {}) !== JSON.stringify(next.vary ?? {}))
1389
+ )
1390
+ throw new ScoreValidationError(
1391
+ `${section.name} and ${next.name} mute or vary differently · section join ${section.name} force`,
1392
+ );
1393
+ const form: FormEntry[] = [];
1394
+ const entries = score.form;
1395
+ for (let index = 0; index < entries.length; index += 1) {
1396
+ const entry = entries[index]!;
1397
+ const isFirst = foldName(entry.section) === foldName(section.name);
1398
+ const isSecond = foldName(entry.section) === foldName(next.name);
1399
+ const following = entries[index + 1];
1400
+ if (
1401
+ isFirst &&
1402
+ (entry.repeat ?? 1) === 1 &&
1403
+ following &&
1404
+ foldName(following.section) === foldName(next.name) &&
1405
+ (following.repeat ?? 1) === 1
1406
+ ) {
1407
+ form.push({ section: section.name });
1408
+ index += 1;
1409
+ continue;
1410
+ }
1411
+ if (isFirst || isSecond)
1412
+ throw new ScoreValidationError(
1413
+ `the form plays ${section.name} and ${next.name} apart · change the form first`,
1414
+ );
1415
+ form.push(entry);
1416
+ }
1417
+ const looping =
1418
+ score.loopSection === section.name || score.loopSection === next.name;
1419
+ return score.withSections(
1420
+ score.sections
1421
+ .filter((candidate) => candidate !== next)
1422
+ .map((candidate) =>
1423
+ candidate === section
1424
+ ? { ...section, bars: section.bars + next.bars }
1425
+ : candidate,
1426
+ ),
1427
+ form,
1428
+ looping ? section.name : score.loopSection,
1429
+ );
1430
+ }
1431
+
1253
1432
  function omit<T extends object, K extends keyof T>(
1254
1433
  value: T,
1255
1434
  key: K,
package/guides/arrange.md CHANGED
@@ -4,18 +4,19 @@ title: Arrange
4
4
  order: 9
5
5
  ---
6
6
 
7
- Notes make tracks, tracks make sections, sections make the form.
7
+ Notes make tracks, bars make sections, sections make the form. Name a
8
+ range of bars and loop, copy or move it.
8
9
 
9
10
  ## Ask
10
11
 
11
- - "make 16 bars of deep house" · "add a chorus that lifts at bar 9"
12
+ - "loop bars 5 to 6" · "copy the bass in 5-6 to bar 7 twice"
12
13
 
13
14
  ## Type it yourself
14
15
 
15
- - `add E4 at 2 for 0.5` · `track bass` · `tracks`
16
- - `section chorus 9-16` · `form verse verse chorus verse`
17
- - `build into chorus` · `drop chorus` · `fill chorus`
18
- - `style deep-house 16` · `bars 8` · `extend 4 bars`
16
+ - `loop 5-6` · `loop chorus` · `loop next` · `loop off`
17
+ - `copy bass 5-6 to 7 x2` · `move bass 5-6 to 9` · `clear bass 5-6`
18
+ - `copy all 1-4` then `paste at 9` · `reverse bass 5-6` · `jump 5`
19
+ - `section chorus 9-16` · `form verse chorus` · `form print`
19
20
 
20
21
  ## Menu
21
22
 
@@ -23,8 +24,8 @@ Notes make tracks, tracks make sections, sections make the form.
23
24
 
24
25
  ## Keys
25
26
 
26
- - `view all` shows every track · Ctrl-O the transcript
27
+ - Ctrl-T TAPE: every track across the bars · `\` loops the section
27
28
 
28
29
  ## Next
29
30
 
30
- - `guide notes` · `guide tracks` · `guide tempo` · `guide styles`
31
+ - `guide tape` · `guide notes` · `guide tracks` · `guide panes`
@@ -0,0 +1,31 @@
1
+ ---
2
+ id: audio
3
+ title: Audio output and input
4
+ parent: project
5
+ order: 3
6
+ ---
7
+
8
+ One output and one input, or the system default. Saved on this
9
+ machine, not in the song. Needs the native sink (`dawg doctor`).
10
+
11
+ ## Ask
12
+
13
+ - "which audio output am I using?"
14
+
15
+ ## Type it yourself
16
+
17
+ - `audio` · `audio out USB Audio Interface` · `audio out default`
18
+ - `audio in Built-in Microphone` · `audio test` (tone, input meter)
19
+
20
+ ## Menu
21
+
22
+ - Ctrl-K › Project › audio › output · input · test
23
+
24
+ ## Keys
25
+
26
+ - In a device list: arrows move, Enter picks (a soft blip plays), Esc
27
+
28
+ ## Next
29
+
30
+ - Unplugged mid-song: playback moves to the default, said once
31
+ - `DAWG_AUDIO_DEVICE` overrides · `guide project`
package/guides/mix.md CHANGED
@@ -11,7 +11,8 @@ order: 8
11
11
 
12
12
  ## Type it yourself
13
13
 
14
- - `volume 0.7` (0..1) · `pan -0.4` (-1..1)
14
+ - `volume 0.7` (0..1) · `pan -0.4` (-1..1) · `volume drums 0.5`
15
+ - `knobs` four knobs for this track · `mix` the mixer page
15
16
  - `mute` · `unmute` · `solo` · `unsolo`
16
17
  - `fx orbit 2` send to a bus · `fx duck preset pump` on the kick
17
18
  - `master streaming` · `master target -14` · `master measure`
@@ -23,6 +24,7 @@ order: 8
23
24
  ## Keys
24
25
 
25
26
  - `volume` or `pan` alone opens a fader to drag or step
27
+ - Knobs: ↑↓ pick ● ▲ ■ ◆ · ←→ turn · ⇧ coarse · x reset · Tab more
26
28
 
27
29
  ## Next
28
30
 
@@ -0,0 +1,32 @@
1
+ ---
2
+ id: panes
3
+ title: Panes
4
+ parent: project
5
+ order: 4
6
+ ---
7
+
8
+ Open the same song in several terminals: one plays, one shows TAPE,
9
+ one the knobs. Each pane has a letter and its own undo.
10
+
11
+ ## Ask
12
+
13
+ - "which panes are open?"
14
+
15
+ ## Type it yourself
16
+
17
+ - `panes` · `pane tape` (copies `dawg pane tape` to paste elsewhere)
18
+ - `pin` keep this pane on its track · `unpin`
19
+ - `follow b` follow pane B's track · `follow` the latest · `unfollow`
20
+ - `undo` this pane's last edit · `undo all` anyone's
21
+
22
+ ## Menu
23
+
24
+ - Ctrl-K › Project › session
25
+
26
+ ## Keys
27
+
28
+ - In a shell: `dawg pane home|play|tape|sound|menu [track] [pin]`
29
+
30
+ ## Next
31
+
32
+ - `guide sessions` · `guide tape` · `guide project`
package/guides/project.md CHANGED
@@ -19,7 +19,7 @@ live here; windows on one folder stay in sync.
19
19
 
20
20
  ## Menu
21
21
 
22
- - Ctrl-K › Project › play · tempo · export · session · agent
22
+ - Ctrl-K › Project › play · tempo · export · session · agent · audio
23
23
 
24
24
  ## Keys
25
25
 
@@ -27,4 +27,5 @@ live here; windows on one folder stay in sync.
27
27
 
28
28
  ## Next
29
29
 
30
- - `guide files` · `guide sessions` · `guide agent`
30
+ - `guide files` · `guide sessions` · `guide agent` · `guide audio`
31
+ - `guide panes`
package/guides/rhythm.md CHANGED
@@ -23,7 +23,8 @@ A drum track has one row per drum: kick, snare, hat and more.
23
23
 
24
24
  ## Keys
25
25
 
26
- - In the euclid editor ↑↓ drum · ←→ adjust · Tab field · Space loop
26
+ - In the euclid editor ↑↓ knob (● drum ▲ pulses ■ rotate
27
+ ◆ velocity) · ←→ turn · Tab field · Space loop
27
28
 
28
29
  ## Next
29
30
 
package/guides/tape.md ADDED
@@ -0,0 +1,32 @@
1
+ ---
2
+ id: tape
3
+ title: TAPE
4
+ parent: arrange
5
+ order: 6
6
+ ---
7
+
8
+ Ctrl-T shows every track across the bars. Each key echoes the command
9
+ it runs, so the prompt teaches the typed form. Esc goes back.
10
+
11
+ ## Ask
12
+
13
+ - "loop the chorus and copy its drums to bar 17"
14
+
15
+ ## Type it yourself
16
+
17
+ - `tape` · `loop chorus` · `copy drums 9-16 to 17` · `jump 5.3`
18
+
19
+ ## Menu
20
+
21
+ - Ctrl-K › Arrange › sections · form
22
+
23
+ ## Keys
24
+
25
+ - ↑↓ pick a knob: ● playhead ▲ loop ■ tempo ◆ volume · ←→ turn it
26
+ - `\` loop the section here · `[` `]` `{` `}` `<` `>` shape the loop
27
+ - `c` copy · `x` cut · `v` paste (`V` inserts) · Delete clear · `~`
28
+
29
+ ## Next
30
+
31
+ - Form repeats are shaded `░`; an edit there says which copies it hits
32
+ - `dawg pane tape` in another terminal · `guide arrange`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/dawg",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -33,6 +33,7 @@
33
33
  "scripts": {
34
34
  "dawg": "bun run src/main.ts",
35
35
  "bench:perf": "bun bench/perf/run.ts",
36
+ "sizes": "bun bench/sizes/matrix.ts",
36
37
  "test": "bun test",
37
38
  "typecheck": "tsc --noEmit",
38
39
  "check": "bun test && bun run typecheck && bun run format",
@@ -1,3 +1,4 @@
1
+ import { newId } from "../../core/ids.ts";
1
2
  import type { CommandHost } from "./command-agent.ts";
2
3
  import { GRANULAR_PARAMS } from "../../core/granular.ts";
3
4
  import {
@@ -201,7 +202,7 @@ export type AgentTurnOptions = Readonly<{
201
202
  signal?: AbortSignal;
202
203
  budget?: AgentBudget;
203
204
  tools?: readonly AgentTool[];
204
- /** Note ID factory; defaults to `${track}-${revision}-${nonce}${index}`. */
205
+ /** Note ID factory; defaults to `newId(track)` (core/ids.ts). */
205
206
  newNoteId?: (trackId: string, revision: number, index: number) => string;
206
207
  }>;
207
208
 
@@ -243,6 +244,7 @@ export const CONTENT_TOOLS: ReadonlySet<string> = new Set([
243
244
  "set_automation",
244
245
  "add_transition",
245
246
  "edit_section",
247
+ "edit_range",
246
248
  "set_form",
247
249
  "extend_loop",
248
250
  "edit_clip",
@@ -322,11 +324,9 @@ export async function runAgentTurn(
322
324
  };
323
325
  const tools = options.tools ?? AGENT_TOOLS;
324
326
  const chatToolList = chatTools(tools);
325
- const nonce = Math.random().toString(36).slice(2, 6);
326
327
  const newNoteId =
327
328
  options.newNoteId ??
328
- ((trackId: string, revision: number, index: number) =>
329
- `${trackId.slice(0, 40)}-${revision}-${nonce}${index}`);
329
+ ((trackId: string, _revision: number, _index: number) => newId(trackId));
330
330
  const deadline = turnDeadline(limits.timeoutMs);
331
331
  const signal = options.signal
332
332
  ? AbortSignal.any([options.signal, deadline.signal])
@@ -10,6 +10,7 @@
10
10
  * sends about a ninth of the input tokens and finishes in about half the
11
11
  * time (docs/show-me.md).
12
12
  */
13
+ import { newId } from "../../core/ids.ts";
13
14
  import { HELP_SECTIONS } from "../commands/help.ts";
14
15
  import { MEDIA_TOOLS } from "../media/tools.ts";
15
16
  import {
@@ -64,15 +65,24 @@ export const COMMAND_MODE_TOOL_NAMES: ReadonlySet<string> = new Set([
64
65
  /** Steps per command-mode turn: each extra one is a correction round. */
65
66
  export const COMMAND_MAX_STEPS = 4;
66
67
 
68
+ /** Reference groups the command agent is not shown. */
69
+ const AGENT_SKIPS: ReadonlySet<string> = new Set([
70
+ "project · window",
71
+ "project · panes",
72
+ "project · audio",
73
+ "agent",
74
+ ]);
75
+
67
76
  function commandReference(): string {
68
- // Song commands only: the window and agent groups change the person's
69
- // window, which the agent never does.
70
- return HELP_SECTIONS.filter(
71
- (section) =>
72
- section.group !== "project · window" && section.group !== "agent",
73
- )
77
+ // Song commands only. Panes, audio devices and the window groups change the person's window or
78
+ // machine; the clipboard row is a two-step buffer the agent never needs.
79
+ return HELP_SECTIONS.filter((section) => !AGENT_SKIPS.has(section.group))
74
80
  .flatMap((section) =>
75
- section.entries.map((entry) => `${entry.command} — ${entry.summary}`),
81
+ section.entries
82
+ .filter(
83
+ (entry) => !/\bpaste\b/.test(`${entry.command} ${entry.summary}`),
84
+ )
85
+ .map((entry) => `${entry.command} — ${entry.summary}`),
76
86
  )
77
87
  .join("\n");
78
88
  }
@@ -81,6 +91,7 @@ export const COMMAND_AGENT_PROMPT = [
81
91
  "You are dawg, a loop composer inside a terminal music workstation, and you teach it by doing.",
82
92
  "Act by writing dawg prompt commands, exactly as a human types them in the prompt bar, one per line. The user watches each command appear in their prompt bar as you write it, and each line runs the moment it ends, so they learn the commands by watching you.",
83
93
  "Prefer the gestures a person would use: a parameter as one value command (fx reverb mix 0.4, volume 0.7, pan -0.3, which the user sees as a fader moving), notes one per line with add (add C4 at 0 for 0.5, played on the user's keyboard as you write them), drums with hit, pattern and euclid on a kit track, a whole groove with groove <name>.",
94
+ "To arrange, name bars: loop 5-6 loops them, copy bass 5-6 to 7 and move bass 5-6 to 9 place them in one step, clear bass 5-6 empties them. Never copy to the clipboard and paste.",
84
95
  "Commands apply to the focused track. /track <name> focuses a track or creates it; instrument kit makes a drum track. Times are beats from 0: in 4/4 musicians' beats 2 and 4 are beats 1 and 3 here, and bar n starts at (n-1)×beats per bar.",
85
96
  "Write commands first, with no numbering, quotes or code fences. Then write one short plain sentence (not a command) that says what changed and names the key command so the user can do it by hand next time.",
86
97
  "If a command fails you get its error; correct it with another command or stop.",
@@ -132,11 +143,9 @@ export async function runCommandAgentTurn(
132
143
  const offered = chatTools(
133
144
  allTools.filter((tool) => COMMAND_MODE_TOOL_NAMES.has(tool.name)),
134
145
  );
135
- const nonce = Math.random().toString(36).slice(2, 6);
136
146
  const newNoteId =
137
147
  options.newNoteId ??
138
- ((trackId: string, revision: number, index: number) =>
139
- `${trackId.slice(0, 40)}-${revision}-${nonce}${index}`);
148
+ ((trackId: string, _revision: number, _index: number) => newId(trackId));
140
149
  const deadline = turnDeadline(limits.timeoutMs);
141
150
  const signal = options.signal
142
151
  ? AbortSignal.any([options.signal, deadline.signal])
package/src/agent/ops.ts CHANGED
@@ -52,6 +52,8 @@ export type AgentOperation =
52
52
  };
53
53
  /** The instrument word as typed (a guitar alias also loads its rig). */
54
54
  word?: string;
55
+ /** `volume drums 0.5`: another track than the focused one. */
56
+ trackId?: string;
55
57
  }
56
58
  | {
57
59
  type: "automation";
@@ -101,6 +103,19 @@ export function parsePrompt(prompt: string): AgentOperation | undefined {
101
103
  return { type: "track", patch: { muted: false } };
102
104
  const volume = text.match(/^(?:volume|vol)\s+(0(?:\.\d+)?|1(?:\.0+)?)$/);
103
105
  if (volume) return { type: "track", patch: { volume: Number(volume[1]) } };
106
+ // `volume drums 0.5` / `pan bass -0.2`: the mixer page's rows name their
107
+ // track, so a level is set without moving the focus.
108
+ const level = text.match(
109
+ /^(volume|vol|pan)\s+([a-z0-9][a-z0-9._-]{0,63})\s+(-?1(?:\.0+)?|-?0(?:\.\d+)?)$/,
110
+ );
111
+ if (level && !(level[1] !== "pan" && level[3]!.startsWith("-"))) {
112
+ const value = Number(level[3]);
113
+ return {
114
+ type: "track",
115
+ patch: level[1] === "pan" ? { pan: value } : { volume: value },
116
+ trackId: level[2]!,
117
+ };
118
+ }
104
119
  const clearAutomation = text.match(
105
120
  /^(?:clear|reset)\s+(?:(volume|pan)\s+)?automation$/,
106
121
  );
@@ -178,6 +178,18 @@ function parseOperation(value: unknown): ScoreOperation {
178
178
  loopSection: (value.loopSection ?? null) as string | null,
179
179
  };
180
180
  }
181
+ if (
182
+ value.type === "setLoop" &&
183
+ (value.loop === null ||
184
+ (isRecord(value.loop) &&
185
+ typeof value.loop.startBar === "number" &&
186
+ typeof value.loop.bars === "number"))
187
+ )
188
+ // The reducer checks the range (TrackScore normalizeLoopRange).
189
+ return {
190
+ type: "setLoop",
191
+ loop: value.loop as { startBar: number; bars: number } | null,
192
+ };
181
193
  if (
182
194
  value.type === "updateTrack" &&
183
195
  typeof value.trackId === "string" &&