@hraness/dawg 0.6.1 → 0.7.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 (253) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/DAWG.md +343 -116
  3. package/README.md +27 -25
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +271 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +182 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/instruments.ts +19 -0
  11. package/core/keys.ts +3 -3
  12. package/core/loop.ts +5 -0
  13. package/core/lyrics.ts +297 -0
  14. package/core/master.ts +3 -3
  15. package/core/resonators.ts +16 -2
  16. package/core/routing.ts +165 -0
  17. package/core/score.ts +832 -13
  18. package/core/sdk/eval-child.ts +7 -2
  19. package/core/sdk/eval.ts +35 -6
  20. package/core/sdk/print.ts +276 -3
  21. package/core/sdk/sync-lyrics.ts +49 -0
  22. package/core/sdk/v1.ts +1849 -42
  23. package/core/sections.ts +292 -26
  24. package/core/sing.ts +815 -0
  25. package/core/style-provenance.ts +80 -0
  26. package/core/styles/africa-mena-southasia.ts +2893 -0
  27. package/core/styles/americas.ts +3810 -0
  28. package/core/styles/art.ts +4993 -0
  29. package/core/styles/base.ts +123 -0
  30. package/core/styles/cycles.ts +106 -0
  31. package/core/styles/electronic.ts +2723 -0
  32. package/core/styles/europe-asia-pacific.ts +2838 -0
  33. package/core/styles/excerpt.ts +29 -0
  34. package/core/styles/gamelan.ts +283 -0
  35. package/core/styles/generate.ts +2199 -0
  36. package/core/styles/index.ts +515 -0
  37. package/core/styles/parts.ts +106 -0
  38. package/core/styles/pop.ts +3189 -0
  39. package/core/styles/rock.ts +2993 -0
  40. package/core/styles/roots.ts +4175 -0
  41. package/core/styles/schema.ts +429 -0
  42. package/core/styles/taxonomy.ts +940 -0
  43. package/core/styles/validate.ts +528 -0
  44. package/core/tempo.ts +32 -2
  45. package/core/tuning.ts +19 -3
  46. package/core/vocoder.ts +524 -0
  47. package/guides/agent.md +29 -0
  48. package/guides/arrange.md +30 -0
  49. package/guides/audition.md +20 -14
  50. package/guides/automation.md +12 -7
  51. package/guides/chords.md +15 -15
  52. package/guides/effects.md +17 -16
  53. package/guides/faders.md +20 -16
  54. package/guides/files.md +13 -9
  55. package/guides/getting-started.md +15 -11
  56. package/guides/keys.md +19 -15
  57. package/guides/media.md +17 -12
  58. package/guides/mix.md +12 -6
  59. package/guides/music.md +23 -8
  60. package/guides/notes.md +15 -9
  61. package/guides/performance.md +15 -13
  62. package/guides/play.md +21 -13
  63. package/guides/project.md +25 -8
  64. package/guides/providers.md +20 -14
  65. package/guides/resample.md +15 -9
  66. package/guides/rhythm.md +17 -13
  67. package/guides/sessions.md +17 -7
  68. package/guides/show-me.md +31 -0
  69. package/guides/sound.md +25 -9
  70. package/guides/sounds.md +15 -13
  71. package/guides/styles.md +31 -0
  72. package/guides/tempo.md +15 -10
  73. package/guides/tracks.md +15 -10
  74. package/guides/tuning.md +32 -0
  75. package/guides/voice.md +31 -0
  76. package/guides/web-search.md +18 -8
  77. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  78. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  79. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  80. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  81. package/native/prebuilt/manifest.json +21 -0
  82. package/package.json +5 -2
  83. package/src/agent/agent.ts +126 -14
  84. package/src/agent/calibration-tools.ts +53 -0
  85. package/src/agent/clip-tools.ts +453 -0
  86. package/src/agent/command-agent.ts +369 -0
  87. package/src/agent/drum-tools.ts +2 -2
  88. package/src/agent/expression-tools.ts +1 -1
  89. package/src/agent/gateway.ts +246 -60
  90. package/src/agent/models.ts +53 -12
  91. package/src/agent/ops.ts +12 -1
  92. package/src/agent/pack-tools.ts +1 -1
  93. package/src/agent/planner.ts +13 -0
  94. package/src/agent/portable-schema.ts +80 -0
  95. package/src/agent/preview-tool.ts +4 -1
  96. package/src/agent/provider.ts +22 -8
  97. package/src/agent/rhythm-tools.ts +1 -1
  98. package/src/agent/section-tools.ts +1 -1
  99. package/src/agent/show-me.ts +497 -0
  100. package/src/agent/steer.ts +15 -0
  101. package/src/agent/style-tools.ts +217 -0
  102. package/src/agent/tool-error.ts +12 -0
  103. package/src/agent/tools.ts +108 -23
  104. package/src/agent/usage.ts +2 -2
  105. package/src/agent/voice-tools.ts +925 -0
  106. package/src/agent/xcb-agent.ts +11 -7
  107. package/src/argv.ts +38 -0
  108. package/src/audio/analysis.ts +253 -0
  109. package/src/audio/arrange.ts +37 -3
  110. package/src/audio/autotune-engine.ts +101 -0
  111. package/src/audio/autotune.ts +640 -0
  112. package/src/audio/clips.ts +240 -0
  113. package/src/audio/doctor.ts +86 -0
  114. package/src/audio/dsp/bandbank.ts +138 -0
  115. package/src/audio/dsp/envelope.ts +10 -0
  116. package/src/audio/dsp/follow.ts +120 -0
  117. package/src/audio/dsp/formant.ts +427 -0
  118. package/src/audio/dsp/glottal.ts +243 -0
  119. package/src/audio/dsp/interp.ts +7 -2
  120. package/src/audio/dsp/lpc.ts +50 -0
  121. package/src/audio/dsp/periodicity.ts +59 -0
  122. package/src/audio/dsp/pitch.ts +995 -0
  123. package/src/audio/dsp/psola.ts +199 -0
  124. package/src/audio/effects/chain.ts +3 -1
  125. package/src/audio/effects/common.ts +43 -0
  126. package/src/audio/effects/convolution.ts +7 -4
  127. package/src/audio/effects/filter.ts +48 -69
  128. package/src/audio/effects/formant.ts +263 -0
  129. package/src/audio/engine.ts +160 -28
  130. package/src/audio/fit.ts +35 -3
  131. package/src/audio/instrument-check.ts +59 -43
  132. package/src/audio/instruments.ts +4 -0
  133. package/src/audio/keys/calibration.ts +56 -0
  134. package/src/audio/keys/electric.ts +8 -1
  135. package/src/audio/keys/engine.ts +13 -1
  136. package/src/audio/keys/piano.ts +22 -2
  137. package/src/audio/kits.ts +135 -6
  138. package/src/audio/live.ts +114 -20
  139. package/src/audio/native.ts +615 -0
  140. package/src/audio/preview.ts +30 -2
  141. package/src/audio/render-worker.ts +2 -0
  142. package/src/audio/renderer.ts +2 -0
  143. package/src/audio/resample.ts +2 -1
  144. package/src/audio/sampler.ts +85 -4
  145. package/src/audio/samples.ts +20 -2
  146. package/src/audio/sing/analysis.ts +193 -0
  147. package/src/audio/sing/engine.ts +949 -0
  148. package/src/audio/strings/bow.ts +48 -5
  149. package/src/audio/strings/engine.ts +8 -2
  150. package/src/audio/synth/oscillators.ts +31 -21
  151. package/src/audio/synth/voice.ts +34 -1
  152. package/src/audio/vocoder/bank.ts +314 -0
  153. package/src/audio/vocoder/carrier.ts +165 -0
  154. package/src/audio/vocoder/control.ts +68 -0
  155. package/src/audio/vocoder/detect.ts +50 -0
  156. package/src/audio/vocoder/index.ts +304 -0
  157. package/src/audio/vocoder/talkbox.ts +143 -0
  158. package/src/audio/wav.ts +500 -77
  159. package/src/audio/winds/engine.ts +5 -1
  160. package/src/audio/winds/trim.ts +28 -4
  161. package/src/audio/winds/trims1.ts +297 -0
  162. package/src/audio/winds/voice.ts +15 -2
  163. package/src/auth/cli.ts +38 -36
  164. package/src/auth/credentials.ts +30 -1
  165. package/src/auth/login.ts +15 -9
  166. package/src/auth/tui.ts +19 -10
  167. package/src/commands/arrange.ts +44 -29
  168. package/src/commands/autotune.ts +421 -0
  169. package/src/commands/calibration.ts +74 -0
  170. package/src/commands/clips.ts +887 -0
  171. package/src/commands/drums.ts +3 -2
  172. package/src/commands/edit.ts +11 -4
  173. package/src/commands/expression.ts +1 -1
  174. package/src/commands/formant.ts +221 -0
  175. package/src/commands/fx.ts +101 -32
  176. package/src/commands/grammar.ts +558 -0
  177. package/src/commands/help.ts +610 -380
  178. package/src/commands/history.ts +18 -0
  179. package/src/commands/keys.ts +8 -8
  180. package/src/commands/modal.ts +1 -1
  181. package/src/commands/music.ts +1 -1
  182. package/src/commands/nearest.ts +53 -0
  183. package/src/commands/pack.ts +9 -2
  184. package/src/commands/param-range.ts +56 -0
  185. package/src/commands/parses.ts +130 -0
  186. package/src/commands/progression.ts +170 -0
  187. package/src/commands/rhythm.ts +3 -0
  188. package/src/commands/rig.ts +3 -24
  189. package/src/commands/sing.ts +478 -0
  190. package/src/commands/strum.ts +13 -1
  191. package/src/commands/style.ts +415 -0
  192. package/src/commands/time.ts +6 -3
  193. package/src/commands/tuning.ts +3 -3
  194. package/src/commands/vocal-pitch.ts +616 -0
  195. package/src/commands/vocal.ts +147 -0
  196. package/src/commands/vocoder.ts +627 -0
  197. package/src/commands/wind.ts +2 -2
  198. package/src/fs/durable.ts +50 -0
  199. package/src/lang/glossary.ts +493 -0
  200. package/src/launch-args.ts +163 -0
  201. package/src/main.ts +1397 -253
  202. package/src/media/cli.ts +20 -3
  203. package/src/media/import.ts +3 -1
  204. package/src/project/check.ts +21 -2
  205. package/src/project/clip-pins.ts +72 -0
  206. package/src/project/init.ts +23 -8
  207. package/src/project/sync.ts +418 -86
  208. package/src/render.ts +20 -1
  209. package/src/session/daemon.ts +3 -0
  210. package/src/session/meta.ts +14 -0
  211. package/src/session/origin.ts +154 -0
  212. package/src/session/port.ts +22 -4
  213. package/src/session/presence.ts +34 -4
  214. package/src/session/protocol.ts +5 -1
  215. package/src/session/rebase.ts +18 -5
  216. package/src/session/receipt.ts +258 -0
  217. package/src/session/store.ts +65 -43
  218. package/src/tui/arrange-menu.ts +65 -31
  219. package/src/tui/audition.ts +1 -1
  220. package/src/tui/euclid.ts +18 -13
  221. package/src/tui/fader.ts +228 -41
  222. package/src/tui/granular-menu.ts +2 -4
  223. package/src/tui/menu-clips.ts +297 -0
  224. package/src/tui/menu-time.ts +20 -13
  225. package/src/tui/menu-voice.ts +405 -0
  226. package/src/tui/menu.ts +759 -177
  227. package/src/tui/modal-menu.ts +6 -6
  228. package/src/tui/performance-menu.ts +5 -2
  229. package/src/tui/play-chords.ts +4 -2
  230. package/src/tui/play-mode.ts +15 -1
  231. package/src/tui/play-session.ts +47 -7
  232. package/src/tui/sing-menu.ts +278 -0
  233. package/src/tui/style-menu.ts +104 -0
  234. package/src/tui/vocoder-menu.ts +244 -0
  235. package/src/tui/wind-menu.ts +3 -3
  236. package/src/version.ts +8 -0
  237. package/src/web/fetch.ts +115 -29
  238. package/tui/activity.ts +180 -9
  239. package/tui/app.ts +274 -40
  240. package/tui/clip-row.ts +132 -0
  241. package/tui/delight.ts +144 -0
  242. package/tui/drawer.ts +70 -22
  243. package/tui/frame-gate.ts +76 -0
  244. package/tui/grammar.ts +112 -63
  245. package/tui/guide.ts +42 -4
  246. package/tui/highway.ts +269 -25
  247. package/tui/hints.ts +192 -0
  248. package/tui/input.ts +60 -9
  249. package/tui/keys.ts +1 -1
  250. package/tui/play-strip.ts +68 -14
  251. package/tui/prompt.ts +1 -1
  252. package/tui/screen.ts +144 -14
  253. package/tui/theme.ts +27 -0
@@ -17,6 +17,8 @@
17
17
  * every newer event. Events whose rewind was compacted away, and events that
18
18
  * did not change the composition (transport), take no part.
19
19
  */
20
+ import type { TrackScore } from "../../core/score.ts";
21
+ import { musicalReceipt } from "../session/receipt.ts";
20
22
  import {
21
23
  isIdentityRewind,
22
24
  rewindComposition,
@@ -67,3 +69,19 @@ export function historyTarget(
67
69
  function changesComposition(event: HistoryEvent): boolean {
68
70
  return event.rewind !== undefined && !isIdentityRewind(event.rewind);
69
71
  }
72
+
73
+ /**
74
+ * The card after undo or redo names the musical change it made
75
+ * (`undid · −1 note on bass (C3)`, `redid · 96 BPM`), and falls back to the
76
+ * revision when the change is not in the music (a rename, a section label).
77
+ */
78
+ export function historyReceipt(
79
+ direction: "undo" | "redo",
80
+ before: TrackScore,
81
+ after: TrackScore,
82
+ revision: number,
83
+ ): string {
84
+ const verb = direction === "undo" ? "undid" : "redid";
85
+ const change = musicalReceipt(before, after);
86
+ return change ? `${verb} · ${change}` : `${verb} · rev ${revision}`;
87
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
- * The modelled piano commands (0.6 keys lane, `core/keys.ts`):
2
+ * The modeled piano commands (0.6 keys lane, `core/keys.ts`):
3
3
  *
4
- * piano the focused track becomes the modelled grand
4
+ * piano the focused track becomes the modeled grand
5
5
  * piano <preset> grand ballad upright felt lofi honkytonk prepared
6
6
  * grand | upright | felt … the same, by the preset word alone
7
7
  * keys list this track's piano settings
@@ -214,7 +214,7 @@ export function parseKeysCommand(prompt: string): KeysCommand | undefined {
214
214
  if (prompt.length > 1_024) return undefined;
215
215
  let words = prompt.trim().toLowerCase().split(/\s+/);
216
216
  // `instrument piano` / `sound grand`: the generic instrument command with
217
- // a keys word is a new write, so it stores the modelled piano.
217
+ // a keys word is a new write, so it stores the modeled piano.
218
218
  if (
219
219
  words.length === 2 &&
220
220
  ["instrument", "sound", "voice"].includes(words[0]!) &&
@@ -455,8 +455,8 @@ export function applyKeysCommand(
455
455
  const piano = isKeysFamily(track.instrument);
456
456
  const elsewhere =
457
457
  track.instrument === "organ"
458
- ? `keys · ${trackId} is the legacy organ; type tonewheel (or combo, pipe) for drawbars and rotary, or piano for the modelled piano`
459
- : `keys · ${trackId} is ${track.instrument}; type piano first (${PIANO_FAMILIES.join(" ")}), ${ELECTRIC_FAMILIES.join(" ")} or ${ORGAN_FAMILIES.join(" ")} for modelled keys`;
458
+ ? `keys · ${trackId} is the legacy organ; type tonewheel (or combo, pipe) for drawbars and rotary, or piano for the modeled piano`
459
+ : `keys · ${trackId} is ${track.instrument}; type piano first (${PIANO_FAMILIES.join(" ")}), ${ELECTRIC_FAMILIES.join(" ")} or ${ORGAN_FAMILIES.join(" ")} for modeled keys`;
460
460
  if (command.type === "keys-list")
461
461
  return {
462
462
  ok: true,
@@ -483,7 +483,7 @@ export function applyKeysCommand(
483
483
  if (track.sampler && command.type !== "keys-preset")
484
484
  return {
485
485
  ok: false,
486
- message: `keys · ${trackId} is a sampler track; type piano to make it a modelled piano`,
486
+ message: `keys · ${trackId} is a sampler track; type piano to make it a modeled piano`,
487
487
  };
488
488
  let patch: TrackPatch;
489
489
  if (command.type === "keys-preset") {
@@ -516,7 +516,7 @@ export function applyKeysCommand(
516
516
  return {
517
517
  ok: false,
518
518
  message: keysSynth
519
- ? `keys · keys shapes the modelled piano; ${trackId} is the keys synth: use synth ${first ?? "decay"} …, or type piano`
519
+ ? `keys · keys shapes the modeled piano; ${trackId} is the keys synth: use synth ${first ?? "decay"} …, or type piano`
520
520
  : elsewhere,
521
521
  };
522
522
  } else if (command.type === "keys-rotary-at") {
@@ -605,7 +605,7 @@ export function applyKeysCommand(
605
605
 
606
606
  /**
607
607
  * The fields a new track named after a piano word starts with
608
- * (`/track piano` → the modelled grand); empty for any other name. `lofi`
608
+ * (`/track piano` → the modeled grand); empty for any other name. `lofi`
609
609
  * is left out: it also names a drum kit and pattern.
610
610
  */
611
611
  export function newPianoTrack(name: string): {
@@ -86,7 +86,7 @@ export function parseModalCommand(prompt: string): ModalCommand | undefined {
86
86
  if (rest.length === 1 && rest[0] === "gamelan")
87
87
  return {
88
88
  type: "modal-hint",
89
- message: `gamelan · ${GAMELAN_PRESETS.join(" ")} · modal gangsa to start · pair two tracks with modal pair <track> · tuning slendro or pelog · ctrl-k Sound › browse sounds › Mallets and bells › Gamelan`,
89
+ message: `gamelan · ${GAMELAN_PRESETS.join(" ")} · modal gangsa to start · pair two tracks with modal pair <track> · tuning slendro or pelog · ctrl-k Sound › instruments › mallets › gamelan`,
90
90
  };
91
91
  if (rest[0] === "preset") {
92
92
  const preset = rest.length === 2 ? modalPresetFor(rest[1]!) : undefined;
@@ -83,7 +83,7 @@ export type EffectAutomationParameter = Exclude<
83
83
  "volume" | "pan"
84
84
  >;
85
85
 
86
- const LANE_ALIASES: Readonly<Record<string, EffectAutomationParameter>> =
86
+ export const LANE_ALIASES: Readonly<Record<string, EffectAutomationParameter>> =
87
87
  Object.freeze({
88
88
  filter: "filter",
89
89
  cutoff: "filter",
@@ -0,0 +1,53 @@
1
+ /**
2
+ * One vocabulary matcher for every "did you mean" in dawg: instruments,
3
+ * effects, styles, kits, rigs, sections, presets, params and verbs.
4
+ */
5
+
6
+ /**
7
+ * Optimal-string-alignment distance: insert, delete, substitute, and an
8
+ * adjacent swap (`/hlep` → `/help`) each cost one edit.
9
+ */
10
+ export function editDistance(a: string, b: string): number {
11
+ let before: number[] = [];
12
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
13
+ for (let i = 1; i <= a.length; i += 1) {
14
+ const row = [i];
15
+ for (let j = 1; j <= b.length; j += 1) {
16
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
17
+ let best = Math.min(
18
+ previous[j]! + 1,
19
+ row[j - 1]! + 1,
20
+ previous[j - 1]! + cost,
21
+ );
22
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1])
23
+ best = Math.min(best, before[j - 2]! + 1);
24
+ row.push(best);
25
+ }
26
+ before = previous;
27
+ previous = row;
28
+ }
29
+ return previous[b.length]!;
30
+ }
31
+
32
+ /**
33
+ * The word in `vocabulary` nearest to `word` (edit distance, adjacent swaps
34
+ * cost one), within one edit for words of four letters or fewer and two
35
+ * otherwise; undefined when nothing is that close or `word` is itself in it.
36
+ */
37
+ export function nearest(
38
+ word: string,
39
+ vocabulary: Iterable<string>,
40
+ ): string | undefined {
41
+ const typed = word.trim().toLowerCase();
42
+ if (!typed) return undefined;
43
+ const limit = typed.length <= 4 ? 1 : 2;
44
+ let best: { word: string; distance: number } | undefined;
45
+ for (const candidate of vocabulary) {
46
+ const lower = candidate.toLowerCase();
47
+ if (lower === typed) return undefined;
48
+ const distance = editDistance(typed, lower);
49
+ if (distance <= limit && (!best || distance < best.distance))
50
+ best = { word: candidate, distance };
51
+ }
52
+ return best?.word;
53
+ }
@@ -124,10 +124,10 @@ export type KitCommand =
124
124
 
125
125
  /** `/kit [bank]` (default kit) and `/kit list`; undefined for other commands. */
126
126
  export function parseKitCommand(command: string): KitCommand | undefined {
127
- const match = command.trim().match(/^\/kit(?:\s+(\S+))?\s*$/i);
127
+ const match = command.trim().match(/^\/?kits?(?:\s+(\S+))?\s*$/i);
128
128
  if (!match) return undefined;
129
129
  const bank = match[1];
130
- if (bank && /^(list|ls)$/i.test(bank)) return { kind: "list" };
130
+ if (bank && /^(list|ls|presets)$/i.test(bank)) return { kind: "list" };
131
131
  return { kind: "set", bank: bank ?? DEFAULT_KIT };
132
132
  }
133
133
 
@@ -389,6 +389,8 @@ export function kitListLines(aliases: BankAliases): string[] {
389
389
  export type CacheReport = Readonly<{
390
390
  packs: CacheUsage & { max: number };
391
391
  assets: CacheUsage & { max: number };
392
+ /** Pitch curves in `.dawg/analysis` (0.7); omitted when empty. */
393
+ analysis?: CacheUsage & { max: number };
392
394
  freed?: Readonly<{ files: number; bytes: number }>;
393
395
  }>;
394
396
 
@@ -408,6 +410,11 @@ export function cacheLines(report: CacheReport): string[] {
408
410
  : []),
409
411
  row("pack downloads", report.packs, "DAWG_PACKS_CACHE_MAX"),
410
412
  row("decoded audio (this project)", report.assets, "DAWG_ASSETS_CACHE_MAX"),
413
+ ...(report.analysis?.files
414
+ ? [
415
+ `pitch analysis (this project) · ${formatBytes(report.analysis.bytes)} of ${formatBytes(report.analysis.max)} · ${report.analysis.files} curve${report.analysis.files === 1 ? "" : "s"} · .dawg/analysis, re-tracked on demand`,
416
+ ]
417
+ : []),
411
418
  "least recently used files go first; this project's sounds are never evicted",
412
419
  "evicted sounds re-fetch by their pinned sha256 on next use",
413
420
  "/pack cache prune [size] · /pack cache clear",
@@ -0,0 +1,56 @@
1
+ /**
2
+ * `✗ synth lpf 99999 · lpf takes 20…20000 Hz · synth lpf 1200`: the range
3
+ * card for a synth or effect parameter, built from the same specs that
4
+ * validate it (core/params), so the card and the check never disagree.
5
+ */
6
+ import type { ParamSpec } from "../../core/params.ts";
7
+ import { SYNTH_PARAMS, synthParamName } from "../../core/synth.ts";
8
+ import { effectSpec } from "../../core/fx.ts";
9
+ import { parseEffectName, parseParamName } from "./fx.ts";
10
+ import { usageError } from "./grammar.ts";
11
+
12
+ /** The range card for the first out-of-range number in the line, if any. */
13
+ export function paramRangeError(line: string): string | undefined {
14
+ const words = line.trim().replace(/^\//, "").split(/\s+/);
15
+ const verb = words[0]?.toLowerCase();
16
+ if (verb === "synth")
17
+ return firstOutOfRange(line, words.slice(1), "synth", (word) => {
18
+ const name = synthParamName(word);
19
+ return name ? [name, SYNTH_PARAMS[name]] : undefined;
20
+ });
21
+ if (verb === "fx" && words[1]) {
22
+ const effect = parseEffectName(words[1]);
23
+ if (!effect) return undefined;
24
+ const params = effectSpec(effect).params;
25
+ return firstOutOfRange(line, words.slice(2), `fx ${effect}`, (word) => {
26
+ const name = parseParamName(effect, word);
27
+ return name ? [name, params[name]] : undefined;
28
+ });
29
+ }
30
+ return undefined;
31
+ }
32
+
33
+ function firstOutOfRange(
34
+ line: string,
35
+ rest: readonly string[],
36
+ prefix: string,
37
+ lookup: (word: string) => [string, ParamSpec | undefined] | undefined,
38
+ ): string | undefined {
39
+ for (let index = 0; index + 1 < rest.length; index += 2) {
40
+ const found = lookup(rest[index]!);
41
+ if (!found) continue;
42
+ const [name, spec] = found;
43
+ if (spec?.kind !== "number") continue;
44
+ const value = Number(rest[index + 1]);
45
+ if (!Number.isFinite(value)) continue;
46
+ if (value >= spec.min && value <= spec.max) continue;
47
+ return usageError(line.trim(), {
48
+ command: name,
49
+ min: spec.min,
50
+ max: spec.max,
51
+ unit: spec.unit ?? "",
52
+ example: `${prefix} ${name} ${spec.default}`,
53
+ });
54
+ }
55
+ return undefined;
56
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Whether a prompt line is a command the human prompt bar runs locally (no
3
+ * model call): every command parser the prompt tries, in one list. The TUI
4
+ * uses it for typo fixes; show-me (src/agent/show-me.ts) uses it in tests to
5
+ * prove every command it ghost-types is one a human can type.
6
+ */
7
+ import type { TrackScore } from "../../core/score.ts";
8
+ import { parsePrompt } from "../agent/ops.ts";
9
+ import { parseSectionCommand } from "./arrange.ts";
10
+ import { parseAutotuneCommand } from "./autotune.ts";
11
+ import { parseClipCommand, parseLyricsCommand } from "./clips.ts";
12
+ import { parsePatternCommand } from "./drums.ts";
13
+ import { parseProgressionCommand } from "./progression.ts";
14
+ import { parseEditCommand } from "./edit.ts";
15
+ import { parseExpressionCommand } from "./expression.ts";
16
+ import { parseFitCommand } from "./fit.ts";
17
+ import { parseFormantCommand, parseVowelCommand } from "./formant.ts";
18
+ import { parseFxCommand } from "./fx.ts";
19
+ import { parseGranularCommand } from "./granular.ts";
20
+ import { parseKeysCommand } from "./keys.ts";
21
+ import { parseMasterCommand } from "./master.ts";
22
+ import { parseModalCommand } from "./modal.ts";
23
+ import { parseMusicCommand } from "./music.ts";
24
+ import { parseKitCommand, parsePackCommand } from "./pack.ts";
25
+ import { parseResampleCommand } from "./resample.ts";
26
+ import { parseRhythmCommand } from "./rhythm.ts";
27
+ import { parseRigCommand } from "./rig.ts";
28
+ import { parseSampleCommand } from "./sample.ts";
29
+ import { parseShiftCommand } from "./shift.ts";
30
+ import { parseSingCommand } from "./sing.ts";
31
+ import { parseStringCommand } from "./string.ts";
32
+ import { parseGuitarCommand, parseStrumCommand } from "./strum.ts";
33
+ import { parseSynthCommand } from "./synth.ts";
34
+ import { parseTimeCommand } from "./time.ts";
35
+ import { parseTuningCommand } from "./tuning.ts";
36
+ import { parseVocalCommand } from "./vocal.ts";
37
+ import { parseVocoderCommand } from "./vocoder.ts";
38
+ import { parseWavetableCommand } from "./wavetable.ts";
39
+ import { parseWindCommand } from "./wind.ts";
40
+ import {
41
+ candidates,
42
+ parseExportCommand,
43
+ parseLoopCommand,
44
+ parseTrackEdit,
45
+ } from "./grammar.ts";
46
+
47
+ /** One parser's reading of a line: its name and what it parsed. */
48
+ export type ParsedCommand = Readonly<{ parser: string; value: unknown }>;
49
+
50
+ /**
51
+ * The first parser that takes `text` exactly as typed, with its result
52
+ * (no grammar rewrites): what the prompt bar would run for this spelling.
53
+ */
54
+ export function parseExact(
55
+ text: string,
56
+ score: TrackScore,
57
+ ): ParsedCommand | undefined {
58
+ for (const parse of parsers(score)) {
59
+ const value = parse(text);
60
+ if (value !== undefined)
61
+ return { parser: parse.name || "anonymous", value };
62
+ }
63
+ return undefined;
64
+ }
65
+
66
+ /**
67
+ * What `text` runs as: the exact spelling first, then the grammar's other
68
+ * readings (`/x` ≡ `x`, aliases, remove and list words) in order.
69
+ */
70
+ export function parseCommand(
71
+ text: string,
72
+ score: TrackScore,
73
+ ): ParsedCommand | undefined {
74
+ for (const line of [text, ...candidates(text)]) {
75
+ const parsed = parseExact(line, score);
76
+ if (parsed) return parsed;
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ /** Whether a line runs locally, in any spelling the grammar accepts. */
82
+ export function commandParses(text: string, score: TrackScore): boolean {
83
+ return parseCommand(text, score) !== undefined;
84
+ }
85
+
86
+ function parsers(score: TrackScore): ((text: string) => unknown)[] {
87
+ return [
88
+ parsePrompt,
89
+ parseMusicCommand,
90
+ parseEditCommand,
91
+ parseRhythmCommand,
92
+ parseFxCommand,
93
+ parseSynthCommand,
94
+ parseStringCommand,
95
+ parseGranularCommand,
96
+ parseKeysCommand,
97
+ parseExpressionCommand,
98
+ parseMasterCommand,
99
+ function parseSection(value: string) {
100
+ return parseSectionCommand(value, score);
101
+ },
102
+ parsePatternCommand,
103
+ parseKitCommand,
104
+ parsePackCommand,
105
+ parseSampleCommand,
106
+ parseFitCommand,
107
+ parseShiftCommand,
108
+ parseResampleCommand,
109
+ parseWavetableCommand,
110
+ parseTimeCommand,
111
+ parseTuningCommand,
112
+ parseRigCommand,
113
+ parseModalCommand,
114
+ parseGuitarCommand,
115
+ parseStrumCommand,
116
+ parseProgressionCommand,
117
+ parseWindCommand,
118
+ parseAutotuneCommand,
119
+ parseSingCommand,
120
+ parseVocoderCommand,
121
+ parseVocalCommand,
122
+ parseFormantCommand,
123
+ parseVowelCommand,
124
+ parseClipCommand,
125
+ parseLyricsCommand,
126
+ parseExportCommand,
127
+ parseLoopCommand,
128
+ parseTrackEdit,
129
+ ];
130
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * `progression …`: sustained, voice-led block chords on the focused track,
3
+ * the prompt form of the SDK's `progression()` and the agent's write_chords.
4
+ *
5
+ * progression i7 IV7 i7 IV7 each 8 roman numerals in the song key
6
+ * progression Am7 D9 at 16 bass chord symbols, a root under each
7
+ *
8
+ * Chords run core/chords.ts renderProgression in `block` mode with voice
9
+ * leading, so a carrier for a vocoder or talkbox holds each chord for its
10
+ * whole span. Existing notes stay; chords past the end grow the song.
11
+ */
12
+ import {
13
+ chordName,
14
+ keyUsesFlats,
15
+ parseKey,
16
+ renderProgression,
17
+ resolveChord,
18
+ type Chord,
19
+ type Key,
20
+ } from "../../core/chords.ts";
21
+ import { applyScoreOperations } from "../../core/diff.ts";
22
+ import {
23
+ SCORE_LIMITS,
24
+ ScoreValidationError,
25
+ type ScoreOperation,
26
+ type TrackScore,
27
+ } from "../../core/score.ts";
28
+ import { barTicks } from "../../core/sections.ts";
29
+ import { resolveTuning, snapToTuning } from "../../core/tuning.ts";
30
+ import type { CommandResult } from "./strum.ts";
31
+
32
+ export const PROGRESSION_USAGE =
33
+ "progression <chords> [each <beats>] [at <beat>] [bass] · progression i7 IV7 each 8 · progression Am7 D9 bass";
34
+
35
+ export type ProgressionOptions = Readonly<{
36
+ chords: readonly string[];
37
+ each?: number;
38
+ at?: number;
39
+ bass?: boolean;
40
+ }>;
41
+
42
+ export type ProgressionCommand =
43
+ | Readonly<{ type: "progression"; options: ProgressionOptions }>
44
+ | Readonly<{ type: "progression-hint"; message: string }>;
45
+
46
+ /** `progression …`, or undefined when the prompt is not one. */
47
+ export function parseProgressionCommand(
48
+ prompt: string,
49
+ ): ProgressionCommand | undefined {
50
+ if (prompt.length > 1_024) return undefined;
51
+ const words = prompt.trim().replace(/^\//, "").split(/\s+/);
52
+ const head = words[0]?.toLowerCase();
53
+ if (head !== "progression" && head !== "prog") return undefined;
54
+ const chords: string[] = [];
55
+ let each: number | undefined;
56
+ let at: number | undefined;
57
+ let bass = false;
58
+ for (let i = 1; i < words.length; i += 1) {
59
+ const word = words[i]!;
60
+ const lower = word.toLowerCase();
61
+ if (lower === "each" || lower === "at") {
62
+ const value = Number(words[i + 1]);
63
+ if (
64
+ !Number.isFinite(value) ||
65
+ value < 0 ||
66
+ (lower === "each" && value <= 0)
67
+ )
68
+ return { type: "progression-hint", message: PROGRESSION_USAGE };
69
+ if (lower === "each") each = value;
70
+ else at = value;
71
+ i += 1;
72
+ } else if (lower === "bass") bass = true;
73
+ else chords.push(word);
74
+ }
75
+ if (chords.length === 0)
76
+ return { type: "progression-hint", message: PROGRESSION_USAGE };
77
+ if (chords.length > 64)
78
+ return {
79
+ type: "progression-hint",
80
+ message: "progression takes up to 64 chords",
81
+ };
82
+ return {
83
+ type: "progression",
84
+ options: {
85
+ chords,
86
+ ...(each !== undefined ? { each } : {}),
87
+ ...(at !== undefined ? { at } : {}),
88
+ ...(bass ? { bass } : {}),
89
+ },
90
+ };
91
+ }
92
+
93
+ /** Writes a progression command's chords onto `trackId`. */
94
+ export function applyProgressionCommand(
95
+ score: TrackScore,
96
+ trackId: string,
97
+ command: ProgressionCommand,
98
+ newId: (index: number) => string,
99
+ ): CommandResult {
100
+ if (command.type === "progression-hint")
101
+ return { ok: false, message: command.message };
102
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
103
+ if (!track) return { ok: false, message: `no track · ${trackId}` };
104
+ const { options } = command;
105
+ const key: Key = parseKey(score.key) ?? { tonic: 0, mode: "major" };
106
+ const chords: Chord[] = [];
107
+ for (const text of options.chords) {
108
+ const chord = resolveChord(key, text);
109
+ if (!chord)
110
+ return {
111
+ ok: false,
112
+ message: `unknown chord ${JSON.stringify(text.slice(0, 32))} (a symbol like Am7 or a numeral like ii7)`,
113
+ };
114
+ chords.push(chord);
115
+ }
116
+ const each = options.each ?? score.beatsPerBar;
117
+ const rendered = renderProgression({
118
+ key,
119
+ chords,
120
+ beatsPerChord: each,
121
+ start: options.at ?? 0,
122
+ bass: options.bass === true,
123
+ lead: true,
124
+ perform: { mode: "block" },
125
+ });
126
+ const performed = [...rendered.notes, ...rendered.bass];
127
+ if (performed.length > 4096)
128
+ return { ok: false, message: "progression would write over 4096 notes" };
129
+ const tpb = score.ticksPerBeat;
130
+ const table = resolveTuning(score.tuning, track.tuning, score.key);
131
+ const operations: ScoreOperation[] = performed.map((note, index) => ({
132
+ type: "addNote",
133
+ note: {
134
+ id: newId(index),
135
+ trackId,
136
+ startTick: Math.round(note.start * tpb),
137
+ durationTicks: Math.max(1, Math.round(note.length * tpb)),
138
+ pitch: snapToTuning(note.pitch, table),
139
+ velocity: note.velocity,
140
+ },
141
+ }));
142
+ let next: TrackScore;
143
+ try {
144
+ next = applyScoreOperations(score, operations);
145
+ } catch (error) {
146
+ if (error instanceof ScoreValidationError)
147
+ return { ok: false, message: `progression · ${error.message}` };
148
+ throw error;
149
+ }
150
+ const end = next.notes.reduce(
151
+ (max, note) => Math.max(max, note.startTick + note.durationTicks),
152
+ 0,
153
+ );
154
+ const needed = Math.min(
155
+ SCORE_LIMITS.maxBars,
156
+ Math.ceil(end / barTicks(next)),
157
+ );
158
+ const grew = needed > next.bars;
159
+ if (grew)
160
+ next = applyScoreOperations(next, [{ type: "setBars", bars: needed }]);
161
+ const flats = keyUsesFlats(key);
162
+ const names = chords.map((chord) => chordName(chord, flats));
163
+ return {
164
+ ok: true,
165
+ message: `progression · ${names.join(" ")} · ${each} beats each · ${performed.length} notes${grew ? ` · song now ${needed} bars` : ""}`,
166
+ next,
167
+ kind: "score.notes",
168
+ payload: { trackId, progression: names },
169
+ };
170
+ }
@@ -120,6 +120,9 @@ export function parseRhythmCommand(prompt: string): RhythmCommand | undefined {
120
120
  fields[key] = Number(raw);
121
121
  }
122
122
  }
123
+ // `euclid hat` alone names a row to edit: the window opens the editor on
124
+ // it (`/euclid hat`), so it is not an empty edit here.
125
+ if (Object.keys(fields).length === 0) return undefined;
123
126
  return { type: "rhythm-set", voice: lowerDrum(voice), fields };
124
127
  }
125
128
 
@@ -45,6 +45,7 @@ import {
45
45
  type FxCommand,
46
46
  type FxResult,
47
47
  } from "./fx.ts";
48
+ import { nearest } from "./nearest.ts";
48
49
 
49
50
  export type RigCommand =
50
51
  | { type: "rig-show" }
@@ -67,33 +68,11 @@ function isStage(word: string): word is RigStage {
67
68
 
68
69
  /** `unknown rig crunh · did you mean crunch? · rigs clean crunch …` */
69
70
  function unknownRigMessage(name: string): string {
70
- let best: { name: string; distance: number } | undefined;
71
- for (const candidate of RIG_PRESET_NAMES) {
72
- const distance = editDistance(name, candidate);
73
- if (!best || distance < best.distance) best = { name: candidate, distance };
74
- }
75
- const near =
76
- best && best.distance <= Math.max(2, Math.floor(name.length / 3))
77
- ? ` · did you mean ${best.name}?`
78
- : "";
71
+ const match = nearest(name, RIG_PRESET_NAMES);
72
+ const near = match ? ` · did you mean ${match}?` : "";
79
73
  return `unknown rig ${name}${near} · rigs ${RIG_PRESET_NAMES.join(" ")} reset`;
80
74
  }
81
75
 
82
- function editDistance(a: string, b: string): number {
83
- let row = Array.from({ length: b.length + 1 }, (_, i) => i);
84
- for (let i = 1; i <= a.length; i++) {
85
- const next = [i];
86
- for (let j = 1; j <= b.length; j++)
87
- next[j] = Math.min(
88
- row[j]! + 1,
89
- next[j - 1]! + 1,
90
- row[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
91
- );
92
- row = next;
93
- }
94
- return row[b.length]!;
95
- }
96
-
97
76
  export function parseRigCommand(prompt: string): RigCommand | undefined {
98
77
  if (prompt.length > 1_024) return undefined;
99
78
  const words = prompt.trim().replace(/^\//, "").toLowerCase().split(/\s+/);