@hraness/dawg 0.6.0 → 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 (275) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +655 -141
  3. package/README.md +27 -25
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +759 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +182 -99
  8. package/core/expression.ts +158 -6
  9. package/core/fx.ts +354 -32
  10. package/core/granular.ts +91 -0
  11. package/core/instruments.ts +180 -0
  12. package/core/keys.ts +573 -4
  13. package/core/loop.ts +5 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/resonators.ts +306 -4
  17. package/core/routing.ts +165 -0
  18. package/core/score.ts +1181 -16
  19. package/core/sdk/eval-child.ts +7 -2
  20. package/core/sdk/eval.ts +35 -6
  21. package/core/sdk/print.ts +354 -6
  22. package/core/sdk/sync-lyrics.ts +49 -0
  23. package/core/sdk/v1.ts +3160 -69
  24. package/core/sections.ts +298 -24
  25. package/core/sing.ts +815 -0
  26. package/core/strings.ts +237 -2
  27. package/core/style-provenance.ts +80 -0
  28. package/core/styles/africa-mena-southasia.ts +2893 -0
  29. package/core/styles/americas.ts +3810 -0
  30. package/core/styles/art.ts +4993 -0
  31. package/core/styles/base.ts +123 -0
  32. package/core/styles/cycles.ts +106 -0
  33. package/core/styles/electronic.ts +2723 -0
  34. package/core/styles/europe-asia-pacific.ts +2838 -0
  35. package/core/styles/excerpt.ts +29 -0
  36. package/core/styles/gamelan.ts +283 -0
  37. package/core/styles/generate.ts +2199 -0
  38. package/core/styles/index.ts +515 -0
  39. package/core/styles/parts.ts +106 -0
  40. package/core/styles/pop.ts +3189 -0
  41. package/core/styles/rock.ts +2993 -0
  42. package/core/styles/roots.ts +4175 -0
  43. package/core/styles/schema.ts +429 -0
  44. package/core/styles/taxonomy.ts +940 -0
  45. package/core/styles/validate.ts +528 -0
  46. package/core/tempo.ts +32 -2
  47. package/core/tuning.ts +19 -3
  48. package/core/vocoder.ts +524 -0
  49. package/core/winds.ts +652 -0
  50. package/guides/agent.md +29 -0
  51. package/guides/arrange.md +30 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -6
  54. package/guides/chords.md +16 -14
  55. package/guides/effects.md +17 -15
  56. package/guides/faders.md +20 -16
  57. package/guides/files.md +13 -9
  58. package/guides/getting-started.md +15 -11
  59. package/guides/keys.md +19 -15
  60. package/guides/media.md +17 -12
  61. package/guides/mix.md +12 -6
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/performance.md +15 -10
  65. package/guides/play.md +21 -13
  66. package/guides/project.md +25 -8
  67. package/guides/providers.md +20 -14
  68. package/guides/resample.md +32 -0
  69. package/guides/rhythm.md +17 -13
  70. package/guides/sessions.md +17 -7
  71. package/guides/show-me.md +31 -0
  72. package/guides/sound.md +25 -9
  73. package/guides/sounds.md +15 -12
  74. package/guides/styles.md +31 -0
  75. package/guides/tempo.md +15 -10
  76. package/guides/tracks.md +15 -10
  77. package/guides/tuning.md +32 -0
  78. package/guides/voice.md +31 -0
  79. package/guides/web-search.md +18 -8
  80. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  81. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  82. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  83. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  84. package/native/prebuilt/manifest.json +21 -0
  85. package/package.json +5 -2
  86. package/src/agent/agent.ts +131 -13
  87. package/src/agent/calibration-tools.ts +53 -0
  88. package/src/agent/chord-tools.ts +153 -1
  89. package/src/agent/clip-tools.ts +453 -0
  90. package/src/agent/command-agent.ts +369 -0
  91. package/src/agent/drum-tools.ts +2 -2
  92. package/src/agent/expression-tools.ts +91 -0
  93. package/src/agent/gateway.ts +246 -60
  94. package/src/agent/models.ts +53 -12
  95. package/src/agent/ops.ts +15 -1
  96. package/src/agent/pack-tools.ts +1 -1
  97. package/src/agent/planner.ts +13 -0
  98. package/src/agent/portable-schema.ts +80 -0
  99. package/src/agent/preview-tool.ts +5 -1
  100. package/src/agent/provider.ts +22 -8
  101. package/src/agent/resample-tool.ts +131 -0
  102. package/src/agent/rhythm-tools.ts +1 -1
  103. package/src/agent/section-tools.ts +1 -1
  104. package/src/agent/show-me.ts +497 -0
  105. package/src/agent/steer.ts +15 -0
  106. package/src/agent/style-tools.ts +217 -0
  107. package/src/agent/tool-error.ts +12 -0
  108. package/src/agent/tools.ts +307 -37
  109. package/src/agent/usage.ts +2 -2
  110. package/src/agent/voice-tools.ts +925 -0
  111. package/src/agent/xcb-agent.ts +16 -6
  112. package/src/argv.ts +38 -0
  113. package/src/audio/analysis.ts +253 -0
  114. package/src/audio/arrange.ts +58 -3
  115. package/src/audio/autotune-engine.ts +101 -0
  116. package/src/audio/autotune.ts +640 -0
  117. package/src/audio/clips.ts +240 -0
  118. package/src/audio/doctor.ts +86 -0
  119. package/src/audio/dsp/bandbank.ts +138 -0
  120. package/src/audio/dsp/envelope.ts +171 -0
  121. package/src/audio/dsp/follow.ts +120 -0
  122. package/src/audio/dsp/formant.ts +427 -0
  123. package/src/audio/dsp/glottal.ts +243 -0
  124. package/src/audio/dsp/interp.ts +7 -2
  125. package/src/audio/dsp/lpc.ts +50 -0
  126. package/src/audio/dsp/periodicity.ts +59 -0
  127. package/src/audio/dsp/pitch.ts +995 -0
  128. package/src/audio/dsp/psola.ts +199 -0
  129. package/src/audio/dsp/shift.ts +256 -0
  130. package/src/audio/effects/chain.ts +21 -4
  131. package/src/audio/effects/common.ts +57 -0
  132. package/src/audio/effects/convolution.ts +113 -5
  133. package/src/audio/effects/filter.ts +48 -69
  134. package/src/audio/effects/formant.ts +263 -0
  135. package/src/audio/effects/gaze.ts +354 -0
  136. package/src/audio/engine.ts +206 -33
  137. package/src/audio/fit.ts +80 -3
  138. package/src/audio/granular.ts +352 -21
  139. package/src/audio/instrument-check.ts +67 -47
  140. package/src/audio/instruments.ts +36 -3
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +427 -0
  143. package/src/audio/keys/engine.ts +156 -20
  144. package/src/audio/keys/organ.ts +1335 -0
  145. package/src/audio/keys/piano.ts +72 -8
  146. package/src/audio/keys/sympathetic.ts +127 -0
  147. package/src/audio/kits.ts +135 -6
  148. package/src/audio/live-worker.ts +3 -1
  149. package/src/audio/live.ts +247 -26
  150. package/src/audio/native.ts +615 -0
  151. package/src/audio/preview.ts +45 -3
  152. package/src/audio/render-worker.ts +2 -0
  153. package/src/audio/renderer.ts +2 -0
  154. package/src/audio/resample.ts +286 -0
  155. package/src/audio/resonators.ts +10 -2
  156. package/src/audio/sampler.ts +252 -24
  157. package/src/audio/samples.ts +46 -3
  158. package/src/audio/sing/analysis.ts +193 -0
  159. package/src/audio/sing/engine.ts +949 -0
  160. package/src/audio/strings/body.ts +29 -16
  161. package/src/audio/strings/bow.ts +699 -0
  162. package/src/audio/strings/engine.ts +274 -22
  163. package/src/audio/strings/measure.test-helpers.ts +2 -0
  164. package/src/audio/synth/oscillators.ts +31 -21
  165. package/src/audio/synth/voice.ts +34 -1
  166. package/src/audio/vocoder/bank.ts +314 -0
  167. package/src/audio/vocoder/carrier.ts +165 -0
  168. package/src/audio/vocoder/control.ts +68 -0
  169. package/src/audio/vocoder/detect.ts +50 -0
  170. package/src/audio/vocoder/index.ts +304 -0
  171. package/src/audio/vocoder/talkbox.ts +143 -0
  172. package/src/audio/wav.ts +608 -85
  173. package/src/audio/winds/engine.ts +306 -0
  174. package/src/audio/winds/filters.ts +153 -0
  175. package/src/audio/winds/pitch.ts +104 -0
  176. package/src/audio/winds/trim.ts +104 -0
  177. package/src/audio/winds/trims.ts +917 -0
  178. package/src/audio/winds/trims1.ts +297 -0
  179. package/src/audio/winds/voice.ts +492 -0
  180. package/src/auth/cli.ts +38 -36
  181. package/src/auth/credentials.ts +30 -1
  182. package/src/auth/login.ts +15 -9
  183. package/src/auth/tui.ts +19 -10
  184. package/src/commands/arrange.ts +44 -29
  185. package/src/commands/autotune.ts +421 -0
  186. package/src/commands/calibration.ts +74 -0
  187. package/src/commands/clips.ts +887 -0
  188. package/src/commands/drums.ts +3 -2
  189. package/src/commands/edit.ts +11 -4
  190. package/src/commands/expression.ts +104 -23
  191. package/src/commands/formant.ts +221 -0
  192. package/src/commands/fx.ts +105 -33
  193. package/src/commands/grammar.ts +558 -0
  194. package/src/commands/granular.ts +7 -2
  195. package/src/commands/help.ts +652 -339
  196. package/src/commands/history.ts +18 -0
  197. package/src/commands/keys.ts +293 -17
  198. package/src/commands/modal.ts +67 -4
  199. package/src/commands/music.ts +1 -1
  200. package/src/commands/nearest.ts +53 -0
  201. package/src/commands/pack.ts +9 -2
  202. package/src/commands/param-range.ts +56 -0
  203. package/src/commands/parses.ts +130 -0
  204. package/src/commands/progression.ts +170 -0
  205. package/src/commands/resample.ts +281 -0
  206. package/src/commands/rhythm.ts +3 -0
  207. package/src/commands/rig.ts +8 -25
  208. package/src/commands/sample.ts +49 -2
  209. package/src/commands/shift.ts +119 -0
  210. package/src/commands/sing.ts +478 -0
  211. package/src/commands/string.ts +28 -2
  212. package/src/commands/strum.ts +485 -0
  213. package/src/commands/style.ts +415 -0
  214. package/src/commands/time.ts +6 -3
  215. package/src/commands/tuning.ts +3 -3
  216. package/src/commands/vocal-pitch.ts +616 -0
  217. package/src/commands/vocal.ts +147 -0
  218. package/src/commands/vocoder.ts +627 -0
  219. package/src/commands/wind.ts +244 -0
  220. package/src/fs/durable.ts +50 -0
  221. package/src/lang/glossary.ts +493 -0
  222. package/src/launch-args.ts +163 -0
  223. package/src/main.ts +1521 -255
  224. package/src/media/cli.ts +20 -3
  225. package/src/media/import.ts +3 -1
  226. package/src/project/check.ts +26 -3
  227. package/src/project/clip-pins.ts +72 -0
  228. package/src/project/init.ts +23 -8
  229. package/src/project/sync.ts +418 -86
  230. package/src/render.ts +36 -1
  231. package/src/session/daemon.ts +3 -0
  232. package/src/session/meta.ts +14 -0
  233. package/src/session/origin.ts +154 -0
  234. package/src/session/port.ts +22 -4
  235. package/src/session/presence.ts +50 -7
  236. package/src/session/protocol.ts +5 -1
  237. package/src/session/rebase.ts +18 -5
  238. package/src/session/receipt.ts +258 -0
  239. package/src/session/store.ts +65 -43
  240. package/src/tui/arrange-menu.ts +65 -31
  241. package/src/tui/audition.ts +1 -1
  242. package/src/tui/euclid.ts +18 -13
  243. package/src/tui/fader.ts +228 -41
  244. package/src/tui/granular-menu.ts +2 -4
  245. package/src/tui/menu-clips.ts +297 -0
  246. package/src/tui/menu-time.ts +20 -13
  247. package/src/tui/menu-voice.ts +405 -0
  248. package/src/tui/menu.ts +1377 -193
  249. package/src/tui/modal-menu.ts +38 -11
  250. package/src/tui/performance-menu.ts +46 -1
  251. package/src/tui/play-chords.ts +108 -5
  252. package/src/tui/play-mode.ts +16 -1
  253. package/src/tui/play-session.ts +81 -16
  254. package/src/tui/sing-menu.ts +278 -0
  255. package/src/tui/style-menu.ts +104 -0
  256. package/src/tui/vocoder-menu.ts +244 -0
  257. package/src/tui/wind-menu.ts +144 -0
  258. package/src/version.ts +8 -0
  259. package/src/web/fetch.ts +115 -29
  260. package/tui/activity.ts +180 -9
  261. package/tui/app.ts +274 -40
  262. package/tui/clip-row.ts +132 -0
  263. package/tui/delight.ts +144 -0
  264. package/tui/drawer.ts +70 -22
  265. package/tui/frame-gate.ts +76 -0
  266. package/tui/grammar.ts +112 -63
  267. package/tui/guide.ts +42 -4
  268. package/tui/highway.ts +269 -25
  269. package/tui/hints.ts +192 -0
  270. package/tui/input.ts +60 -9
  271. package/tui/keys.ts +1 -1
  272. package/tui/play-strip.ts +68 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +144 -14
  275. package/tui/theme.ts +27 -0
package/src/auth/login.ts CHANGED
@@ -135,7 +135,7 @@ export async function login(
135
135
  const selection = await selectProvider(deps.auth);
136
136
  if (selection.kind !== "offline") {
137
137
  deps.io.print(
138
- `Signed in · ${providerLabel(selection)}. Switch with \`dawg login <gateway|openrouter|codex|claude>\` or \`dawg model\`.`,
138
+ `Agent key · ${providerLabel(selection)}. Switch with \`dawg model key <gateway|openrouter|codex|claude>\` or \`dawg model\`.`,
139
139
  );
140
140
  return 0;
141
141
  }
@@ -165,7 +165,7 @@ async function loginPick(
165
165
  if (!best) {
166
166
  io.print("No AI provider is set up, and this is not a terminal.");
167
167
  io.print(
168
- "Set AI_GATEWAY_API_KEY or OPENROUTER_API_KEY, or run `dawg login` in a terminal.",
168
+ "Set AI_GATEWAY_API_KEY or OPENROUTER_API_KEY, or run `dawg model key` in a terminal.",
169
169
  );
170
170
  return 1;
171
171
  }
@@ -269,7 +269,7 @@ async function loginGateway(
269
269
  if (!who) {
270
270
  if (!io.interactive) {
271
271
  io.print(
272
- "Not logged in to Vercel. Run `vercel login` first, or re-run `dawg login` in a terminal.",
272
+ "Not logged in to Vercel. Run `vercel login` first, or re-run `dawg model key` in a terminal.",
273
273
  );
274
274
  return 1;
275
275
  }
@@ -319,7 +319,7 @@ async function loginGateway(
319
319
  io.print(
320
320
  `Could not create a key${detail ? `: ${redactKeys(detail)}` : ` (exit ${created.code})`}.`,
321
321
  );
322
- io.print("You can paste one instead with `dawg login --key`.");
322
+ io.print("You can paste one instead with `dawg model key --key`.");
323
323
  return 1;
324
324
  }
325
325
  return saveAndReport(deps, "gateway", key, "created");
@@ -622,7 +622,7 @@ async function loginSubscription(
622
622
  );
623
623
  io.print(` Install: ${XCB_INSTALL}`);
624
624
  io.print(` Docs: ${XCB_DOC_URL}`);
625
- io.print(`Then run \`dawg login ${family}\` again (or set XCB_BIN).`);
625
+ io.print(`Then run \`dawg model key ${family}\` again (or set XCB_BIN).`);
626
626
  return 1;
627
627
  }
628
628
  const read = async (): Promise<XcbAccount[] | undefined> => {
@@ -657,7 +657,7 @@ async function loginSubscription(
657
657
  const setup = `xcb setup ${family}${mine.length > 0 ? " --new" : ""}`;
658
658
  if (!io.interactive) {
659
659
  io.print(
660
- `No ${FAMILY_TITLES[family]} is ready. Run \`${setup}\`, then \`dawg login ${family}\`.`,
660
+ `No ${FAMILY_TITLES[family]} is ready. Run \`${setup}\`, then \`dawg model key ${family}\`.`,
661
661
  );
662
662
  return 1;
663
663
  }
@@ -669,7 +669,9 @@ async function loginSubscription(
669
669
  .trim()
670
670
  .toLowerCase();
671
671
  if (answer !== "" && answer !== "y" && answer !== "yes") {
672
- io.print(`Run \`${setup}\` when ready, then \`dawg login ${family}\`.`);
672
+ io.print(
673
+ `Run \`${setup}\` when ready, then \`dawg model key ${family}\`.`,
674
+ );
673
675
  return 1;
674
676
  }
675
677
  const result = await auth.runner.run(bin, setup.split(" ").slice(1), {
@@ -809,7 +811,9 @@ async function loginXcb(deps: LoginDeps, preset?: XcbPreset): Promise<number> {
809
811
  );
810
812
  io.print(` Install: ${XCB_INSTALL}`);
811
813
  io.print(` Docs: ${XCB_DOC_URL}`);
812
- io.print("Then run `dawg login --xcb` again (or set XCB_BIN to its path).");
814
+ io.print(
815
+ "Then run `dawg model key --xcb` again (or set XCB_BIN to its path).",
816
+ );
813
817
  return 1;
814
818
  }
815
819
  let accounts: readonly XcbAccount[];
@@ -1065,7 +1069,9 @@ export async function logout(
1065
1069
  patch.provider = undefined;
1066
1070
  patch.gatewayModel = undefined;
1067
1071
  patch.openrouterModel = undefined;
1068
- io.print("Cleared the saved provider and model; `dawg login` asks again.");
1072
+ io.print(
1073
+ "Cleared the saved provider and model; `dawg model key` asks again.",
1074
+ );
1069
1075
  } else if (target === "gateway") patch.gatewayModel = undefined;
1070
1076
  else if (target === "openrouter") patch.openrouterModel = undefined;
1071
1077
  patch.setup = undefined;
package/src/auth/tui.ts CHANGED
@@ -51,7 +51,7 @@ export async function tuiAuthCommand(
51
51
  else lines.push(`unknown auth command: ${(name ?? "").slice(0, 20)}`);
52
52
  } catch (error) {
53
53
  lines.push(
54
- `auth failed · ${error instanceof Error ? error.message : String(error)} · dawg login in a shell`,
54
+ `auth failed · ${error instanceof Error ? error.message : String(error)} · dawg model key in a shell`,
55
55
  );
56
56
  }
57
57
  return lines.map((line) => line.slice(0, 240));
@@ -86,22 +86,31 @@ export async function modelPickerItems(
86
86
  }));
87
87
  }
88
88
 
89
- /** `/model <value>`: save for the active provider; returns the receipt. */
89
+ /**
90
+ * `/model <value>`: save for the active provider; returns the receipt and
91
+ * whether it worked, so the caller shows a refusal as one (never a ✓).
92
+ */
90
93
  export async function tuiSetModel(
91
94
  value: string,
92
95
  runner: CommandRunner = systemRunner,
93
- ): Promise<string> {
94
- const auth = defaultAuthEnv(runner);
96
+ auth = defaultAuthEnv(runner),
97
+ ): Promise<{ readonly ok: boolean; readonly text: string }> {
95
98
  const selection = await selectProvider(auth);
96
99
  if (selection.kind === "offline")
97
- return `model · unavailable: ${selection.reason}`;
100
+ return { ok: false, text: `model · unavailable: ${selection.reason}` };
98
101
  try {
99
- return `model · ${await saveModelChoice(auth, selection, value)}`;
102
+ return {
103
+ ok: true,
104
+ text: `model · ${await saveModelChoice(auth, selection, value)}`,
105
+ };
100
106
  } catch (error) {
101
107
  const rows = await modelPickerItems(selection, runner).catch(() => []);
102
- return `model · ${error instanceof Error ? error.message : String(error)}; try ${rows
103
- .slice(0, 8)
104
- .map((row) => row.label)
105
- .join(", ")} · current ${providerLabel(selection)}`;
108
+ return {
109
+ ok: false,
110
+ text: `model · ${error instanceof Error ? error.message : String(error)}; try ${rows
111
+ .slice(0, 8)
112
+ .map((row) => row.label)
113
+ .join(", ")} · current ${providerLabel(selection)}`,
114
+ };
106
115
  }
107
116
  }
@@ -30,6 +30,7 @@ import {
30
30
  type Section,
31
31
  type TrackScore,
32
32
  } from "../../core/score.ts";
33
+ import { nearest } from "./nearest.ts";
33
34
  import {
34
35
  addSection,
35
36
  arrangedBars,
@@ -240,41 +241,17 @@ function unknownSection(
240
241
  return { type: "section-unknown", sub, name };
241
242
  }
242
243
 
243
- function editDistance(a: string, b: string): number {
244
- const row = Array.from({ length: b.length + 1 }, (_, index) => index);
245
- for (let i = 1; i <= a.length; i += 1) {
246
- let previous = row[0]!;
247
- row[0] = i;
248
- for (let j = 1; j <= b.length; j += 1) {
249
- const current = row[j]!;
250
- row[j] = Math.min(
251
- row[j]! + 1,
252
- row[j - 1]! + 1,
253
- previous + (a[i - 1] === b[j - 1] ? 0 : 1),
254
- );
255
- previous = current;
256
- }
257
- }
258
- return row[b.length]!;
259
- }
260
-
261
244
  /** `no section named chrous (sections: …) · did you mean chorus?` */
262
245
  function unknownSectionMessage(score: TrackScore, name: string): string {
263
- const wanted = name.toLowerCase();
264
- let best: { name: string; distance: number } | undefined;
265
- for (const section of score.sections) {
266
- const distance = editDistance(wanted, section.name.toLowerCase());
267
- if (!best || distance < best.distance)
268
- best = { name: section.name, distance };
269
- }
246
+ const match = nearest(
247
+ name,
248
+ score.sections.map((section) => section.name),
249
+ );
270
250
  const list =
271
251
  score.sections.length > 0
272
252
  ? ` (sections: ${score.sections.map((s) => s.name).join(", ")})`
273
253
  : " (no sections yet: section verse 1-8 marks one)";
274
- const near =
275
- best && best.distance <= Math.max(2, Math.floor(wanted.length / 3))
276
- ? ` · did you mean ${best.name}?`
277
- : "";
254
+ const near = match ? ` · did you mean ${match}?` : "";
278
255
  return `section · no section named ${name}${list}${near}`;
279
256
  }
280
257
 
@@ -947,3 +924,41 @@ function describeVary(vary: { transpose?: number; gain?: number }): string {
947
924
  if (vary.gain !== undefined) bits.push(`gain ${vary.gain}`);
948
925
  return bits.join(" ");
949
926
  }
927
+
928
+ /**
929
+ * `loop 2-3` when no section spans those bars: mark the section named
930
+ * `loop` over them (moving it if it exists) and loop it, as one next score.
931
+ */
932
+ export function loopSpan(
933
+ score: TrackScore,
934
+ trackId: string,
935
+ from: number,
936
+ to: number,
937
+ ):
938
+ | Readonly<{ ok: true; next: TrackScore; name: string; message: string }>
939
+ | Readonly<{ ok: false; message: string }> {
940
+ const bars = from === to ? `${from}` : `${from}-${to}`;
941
+ const name = "loop";
942
+ if (!Number.isInteger(from) || !Number.isInteger(to) || from < 1 || to < from)
943
+ return { ok: false, message: `loop ${bars} · bars run low-high from 1` };
944
+ const marked = applySectionCommand(score, trackId, {
945
+ type: "section-mark",
946
+ name,
947
+ startBar: from - 1,
948
+ bars: to - from + 1,
949
+ });
950
+ if (!marked.ok || !marked.next)
951
+ return { ok: false, message: `loop ${bars} · ${marked.message}` };
952
+ const looped = applySectionCommand(marked.next, trackId, {
953
+ type: "section-loop",
954
+ name,
955
+ });
956
+ if (!looped.ok || !looped.next)
957
+ return { ok: false, message: `loop ${bars} · ${looped.message}` };
958
+ return {
959
+ ok: true,
960
+ next: looped.next,
961
+ name,
962
+ message: `loop · bars ${from === to ? from : `${from}–${to}`} · section ${name} · loop off plays the song`,
963
+ };
964
+ }
@@ -0,0 +1,421 @@
1
+ /**
2
+ * The `autotune` prompt command (0.7): pitch correction on the focused
3
+ * track's clips and sampler voices (core/autotune.ts). `/vocal autotune`
4
+ * is the same command; the menu and the `autotune_vocal` agent tool build
5
+ * the same edits through `nextAutotune`.
6
+ *
7
+ * autotune pop on the focused track (or show it)
8
+ * autotune <preset> hard robot warble trap pop natural
9
+ * gentle guided locked
10
+ * autotune <preset> <field> <v>… a preset with overrides
11
+ * autotune speed 35 relax 0.4 fields (base pop)
12
+ * autotune to chord follow the chords
13
+ * autotune to notes melody note-guided from another track
14
+ * autotune key D bayati a key for the scale target
15
+ * autotune voice alto the tracking range
16
+ * autotune <field> off back to the preset's value
17
+ * autotune reset keep only the preset
18
+ * autotune off remove autotune
19
+ * autotune presets the presets with one-line characters
20
+ *
21
+ * `/tune` stays the alias of `/tuning`: `/tune hard` explains the
22
+ * difference and points here. One `updateTrack` revision per command.
23
+ */
24
+ import {
25
+ AUTOTUNE_FIELDS,
26
+ AUTOTUNE_PARAMS,
27
+ AUTOTUNE_PRESET_HELP,
28
+ AUTOTUNE_PRESETS,
29
+ AUTOTUNE_TARGETS,
30
+ AUTOTUNE_VOICES,
31
+ describeAutotune,
32
+ isAutotunePreset,
33
+ normalizeAutotune,
34
+ resolveAutotune,
35
+ type AutotunePreset,
36
+ type TrackAutotune,
37
+ } from "../../core/autotune.ts";
38
+ import { parseKey } from "../../core/chords.ts";
39
+ import { FxValidationError } from "../../core/params.ts";
40
+ import {
41
+ ScoreValidationError,
42
+ updateTrack,
43
+ type TrackScore,
44
+ } from "../../core/score.ts";
45
+ import { autotuneEngineNote, hasGuideNotes } from "../audio/autotune.ts";
46
+ import { nearest } from "./nearest.ts";
47
+
48
+ /** A field value from a command: `null` returns it to the preset's value. */
49
+ export type AutotuneValue = number | string | null;
50
+
51
+ export type AutotuneCommand =
52
+ | { type: "autotune-show" }
53
+ | { type: "autotune-list" }
54
+ | { type: "autotune-reset" }
55
+ | { type: "autotune-off" }
56
+ | { type: "autotune-usage"; message: string }
57
+ | {
58
+ type: "autotune-set";
59
+ preset?: AutotunePreset;
60
+ values: Readonly<Partial<Record<keyof TrackAutotune, AutotuneValue>>>;
61
+ };
62
+
63
+ export const AUTOTUNE_USAGE =
64
+ "autotune [preset] [field value …] | autotune to scale|chromatic|chord|notes [track] | autotune key <key> | autotune off | autotune presets";
65
+
66
+ /** Fields the command takes, in type order (`preset` is a leading word). */
67
+ const FIELD_WORDS = AUTOTUNE_FIELDS.filter((field) => field !== "preset");
68
+
69
+ /** Short spellings people type. */
70
+ const FIELD_ALIASES: Readonly<Record<string, keyof TrackAutotune>> = {
71
+ target: "to",
72
+ rate: "vib",
73
+ depth: "vibmod",
74
+ mix: "amount",
75
+ retune: "speed",
76
+ };
77
+
78
+ function fieldName(word: string): keyof TrackAutotune | undefined {
79
+ if ((FIELD_WORDS as readonly string[]).includes(word))
80
+ return word as keyof TrackAutotune;
81
+ return FIELD_ALIASES[word];
82
+ }
83
+
84
+ function rangeOf(name: keyof TrackAutotune): string {
85
+ const spec = AUTOTUNE_PARAMS.find((param) => param.name === name);
86
+ if (name === "glide" && spec)
87
+ return `glide is 0..${spec.max * 1000} ms (glide 40 or 40ms)`;
88
+ if (spec)
89
+ return `${name} is ${spec.min}..${spec.max}${spec.unit ? ` ${spec.unit}` : ""}`;
90
+ if (name === "to") return `to is one of ${AUTOTUNE_TARGETS.join(" ")}`;
91
+ if (name === "voice") return `voice is one of ${AUTOTUNE_VOICES.join(" ")}`;
92
+ return `${name} needs a value`;
93
+ }
94
+
95
+ const OFF = /^(off|unset|default)$/;
96
+
97
+ /** Fields in time units: speed and hold store ms, glide stores seconds. */
98
+ const TIME_FIELDS: Readonly<Partial<Record<keyof TrackAutotune, number>>> = {
99
+ speed: 1,
100
+ hold: 1,
101
+ glide: 0.001,
102
+ };
103
+
104
+ /** Suffixes each numeric field takes besides a bare number. */
105
+ const UNIT_SUFFIX: Readonly<Partial<Record<keyof TrackAutotune, RegExp>>> = {
106
+ vib: /^hz$/,
107
+ vibmod: /^st$/,
108
+ };
109
+
110
+ /**
111
+ * A field's number, or the message why not. Time fields read like /glide:
112
+ * a bare number is ms, `ms` and `s` convert (`glide 40`, `speed 0.2s`),
113
+ * and a bare fraction of a millisecond is a unit slip. `%` scales 0..1
114
+ * fields. A suffix that does not fit the field is refused.
115
+ */
116
+ function fieldNumber(
117
+ name: keyof TrackAutotune,
118
+ value: string,
119
+ ): number | string {
120
+ const match = /^(-?(?:\d+\.?\d*|\.\d+))([a-z%]*)$/.exec(value);
121
+ if (!match) return rangeOf(name);
122
+ const number = Number(match[1]);
123
+ const suffix = match[2]!;
124
+ const toUnit = TIME_FIELDS[name];
125
+ if (toUnit !== undefined) {
126
+ if (suffix === "ms") return number * toUnit;
127
+ if (suffix === "s") return number * 1000 * toUnit;
128
+ if (suffix !== "")
129
+ return `${name} takes ms or s, not ${suffix.slice(0, 4)}`;
130
+ if (number > 0 && number < 1)
131
+ return `${name} · a bare number is ms · write ${name} ${Math.round(number * 1000)}ms or ${name} ${match[1]}s`;
132
+ const spec = AUTOTUNE_PARAMS.find((param) => param.name === name);
133
+ if (spec && (number * toUnit < spec.min || number * toUnit > spec.max))
134
+ return rangeOf(name);
135
+ return number * toUnit;
136
+ }
137
+ if (suffix === "") return number;
138
+ if (suffix === "%") {
139
+ const spec = AUTOTUNE_PARAMS.find((param) => param.name === name);
140
+ if (spec && spec.max === 1) return number / 100;
141
+ if (spec && spec.max === 100) return number;
142
+ }
143
+ if (UNIT_SUFFIX[name]?.test(suffix)) return number;
144
+ return `${name} takes no ${suffix.slice(0, 4)} · ${rangeOf(name)}`;
145
+ }
146
+
147
+ /** Parses `/autotune …`, `/vocal autotune …` args, and `/tune <preset>`. */
148
+ export function parseAutotuneCommand(
149
+ prompt: string,
150
+ ): AutotuneCommand | undefined {
151
+ if (prompt.length > 512) return undefined;
152
+ const raw = prompt.trim().replace(/^\//, "").split(/\s+/);
153
+ const verb = raw[0]?.toLowerCase();
154
+ // `/tune hard`: `/tune` is `/tuning`; an autotune word gets a pointer.
155
+ if (verb === "tune") {
156
+ const next = raw[1]?.toLowerCase();
157
+ return raw.length >= 2 && (isAutotunePreset(next) || next === "autotune")
158
+ ? {
159
+ type: "autotune-usage",
160
+ message:
161
+ `/tune sets the tuning (edo, ratios, scl) · for pitch correction use /autotune ${isAutotunePreset(next) ? next : ""}`.trimEnd(),
162
+ }
163
+ : undefined;
164
+ }
165
+ if (verb !== "autotune") return undefined;
166
+ return parseAutotuneArgs(raw.slice(1));
167
+ }
168
+
169
+ /** The words after `autotune` (also `/vocal autotune <args>`). */
170
+ export function parseAutotuneArgs(raw: readonly string[]): AutotuneCommand {
171
+ const words = raw.filter((word) => word.length > 0);
172
+ const lower = words.map((word) => word.toLowerCase());
173
+ if (lower.length === 0) return { type: "autotune-show" };
174
+ if (lower.length === 1) {
175
+ const only = lower[0]!;
176
+ if (only === "off" || only === "none" || only === "remove")
177
+ return { type: "autotune-off" };
178
+ if (only === "reset") return { type: "autotune-reset" };
179
+ if (only === "list" || only === "presets") return { type: "autotune-list" };
180
+ if (only === "on") return { type: "autotune-set", values: {} };
181
+ }
182
+ let index = 0;
183
+ let preset: AutotunePreset | undefined;
184
+ if (lower[0] === "preset") {
185
+ if (!isAutotunePreset(lower[1]))
186
+ return {
187
+ type: "autotune-usage",
188
+ message: `preset is one of ${AUTOTUNE_PRESETS.join(" ")}`,
189
+ };
190
+ preset = lower[1];
191
+ index = 2;
192
+ } else if (isAutotunePreset(lower[0])) {
193
+ preset = lower[0];
194
+ index = 1;
195
+ }
196
+ const values: Partial<Record<keyof TrackAutotune, AutotuneValue>> = {};
197
+ while (index < lower.length) {
198
+ const word = lower[index]!;
199
+ const name = fieldName(word);
200
+ if (!name && isAutotunePreset(word))
201
+ return {
202
+ type: "autotune-usage",
203
+ message: preset
204
+ ? `one preset at a time (${preset} or ${word})`
205
+ : `the preset comes first · autotune ${word} …`,
206
+ };
207
+ if (!name) {
208
+ const near = nearest(word.slice(0, 24), [
209
+ ...FIELD_WORDS,
210
+ ...AUTOTUNE_PRESETS,
211
+ ]);
212
+ return {
213
+ type: "autotune-usage",
214
+ message: `no field or preset ${word.slice(0, 24)}${near ? ` · did you mean ${near}?` : ""} · ${AUTOTUNE_USAGE}`,
215
+ };
216
+ }
217
+ index += 1;
218
+ const value = lower[index];
219
+ if (value === undefined)
220
+ return { type: "autotune-usage", message: rangeOf(name) };
221
+ if (OFF.test(value)) {
222
+ values[name] = null;
223
+ index += 1;
224
+ continue;
225
+ }
226
+ if (name === "to") {
227
+ const target = value === "melody" || value === "note" ? "notes" : value;
228
+ if (!(AUTOTUNE_TARGETS as readonly string[]).includes(target))
229
+ return { type: "autotune-usage", message: rangeOf(name) };
230
+ values.to = target;
231
+ index += 1;
232
+ // `to notes melody`: a track id (anything that is not a field word).
233
+ const from = words[index];
234
+ if (
235
+ target === "notes" &&
236
+ from !== undefined &&
237
+ !fieldName(from.toLowerCase())
238
+ ) {
239
+ values.from = from;
240
+ index += 1;
241
+ }
242
+ continue;
243
+ }
244
+ if (name === "from") {
245
+ values.from = words[index]!;
246
+ index += 1;
247
+ continue;
248
+ }
249
+ if (name === "voice") {
250
+ if (!(AUTOTUNE_VOICES as readonly string[]).includes(value))
251
+ return { type: "autotune-usage", message: rangeOf(name) };
252
+ values.voice = value;
253
+ index += 1;
254
+ continue;
255
+ }
256
+ if (name === "key") {
257
+ // `key D bayati`, `key A minor`: words up to the next field word.
258
+ const parts: string[] = [];
259
+ while (index < words.length && !fieldName(lower[index]!)) {
260
+ parts.push(words[index]!);
261
+ index += 1;
262
+ }
263
+ const text = parts.join(" ");
264
+ if (!parseKey(text))
265
+ return {
266
+ type: "autotune-usage",
267
+ message: `key ${text.slice(0, 32)} is not a key (A minor, D bayati, C# major)`,
268
+ };
269
+ values.key = text;
270
+ continue;
271
+ }
272
+ const number = fieldNumber(name, value);
273
+ if (typeof number === "string")
274
+ return { type: "autotune-usage", message: number };
275
+ values[name] = number;
276
+ index += 1;
277
+ }
278
+ return preset
279
+ ? { type: "autotune-set", preset, values }
280
+ : { type: "autotune-set", values };
281
+ }
282
+
283
+ /** One line per preset, for `autotune presets` and the agent brief. */
284
+ export function autotuneListLines(): string[] {
285
+ return AUTOTUNE_PRESETS.map(
286
+ (name) => `${name.padEnd(8)} ${AUTOTUNE_PRESET_HELP[name]}`,
287
+ );
288
+ }
289
+
290
+ /**
291
+ * The autotune field a command leaves on a track (pure; the menu and the
292
+ * agent tool share it). A preset switch keeps the overrides; `reset`
293
+ * keeps only the preset; a bare `autotune` on a track without it is pop.
294
+ */
295
+ export function nextAutotune(
296
+ current: TrackAutotune | undefined,
297
+ command: Extract<
298
+ AutotuneCommand,
299
+ { type: "autotune-reset" | "autotune-set" }
300
+ >,
301
+ instrument?: string,
302
+ ): TrackAutotune {
303
+ if (command.type === "autotune-reset")
304
+ return normalizeAutotune({ preset: current?.preset ?? "pop" }, instrument)!;
305
+ const base: Record<string, unknown> = { ...current };
306
+ if (command.preset) base.preset = command.preset;
307
+ for (const [key, value] of Object.entries(command.values)) {
308
+ if (value === null) delete base[key];
309
+ else base[key] = value;
310
+ }
311
+ // A target other than notes drops a stale `from`.
312
+ if (base.to !== undefined && base.to !== "notes") delete base.from;
313
+ if (Object.keys(base).length === 0) base.preset = "pop";
314
+ return normalizeAutotune(base, instrument)!;
315
+ }
316
+
317
+ /** `· no guide notes · try autotune guided from lead`. */
318
+ function noGuideHint(
319
+ score: TrackScore,
320
+ trackId: string,
321
+ preset: string | undefined,
322
+ ): string {
323
+ const melodic = score.tracks
324
+ .filter(
325
+ (other) =>
326
+ other.id !== trackId &&
327
+ other.instrument !== "kit" &&
328
+ other.kit === undefined &&
329
+ hasGuideNotes(score, other.id),
330
+ )
331
+ .map((other) => other.id)
332
+ .slice(0, 4);
333
+ const word = preset ?? "guided";
334
+ return melodic.length > 0
335
+ ? ` · no guide notes on ${trackId} · try autotune ${word} from ${melodic[0]}${melodic.length > 1 ? ` (or ${melodic.slice(1).join(", ")})` : ""}`
336
+ : ` · no guide notes on ${trackId} · write notes on it or autotune ${word} from <track>`;
337
+ }
338
+
339
+ export type AutotuneResult = Readonly<{
340
+ ok: boolean;
341
+ message: string;
342
+ next?: TrackScore;
343
+ kind?: string;
344
+ payload?: Record<string, unknown>;
345
+ }>;
346
+
347
+ export function applyAutotuneCommand(
348
+ score: TrackScore,
349
+ trackId: string,
350
+ command: AutotuneCommand,
351
+ ): AutotuneResult {
352
+ if (command.type === "autotune-usage")
353
+ return { ok: false, message: `autotune · ${command.message}` };
354
+ if (command.type === "autotune-list")
355
+ return {
356
+ ok: true,
357
+ message: `autotune presets · ${AUTOTUNE_PRESETS.join(" ")}`,
358
+ };
359
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
360
+ if (!track) return { ok: false, message: `no track · ${trackId}` };
361
+ const current = track.autotune;
362
+ if (command.type === "autotune-show" && current)
363
+ return { ok: true, message: `autotune · ${describeAutotune(current)}` };
364
+ if (command.type === "autotune-off") {
365
+ if (!current) return { ok: true, message: "autotune · already off" };
366
+ const next = updateTrack(score, trackId, { autotune: null });
367
+ return {
368
+ ok: true,
369
+ message: `autotune · off · /autotune ${current.preset ?? "pop"} turns it back on`,
370
+ next,
371
+ kind: "score.autotune",
372
+ payload: { trackId, autotune: null },
373
+ };
374
+ }
375
+ let autotune: TrackAutotune;
376
+ let next: TrackScore;
377
+ try {
378
+ autotune = nextAutotune(
379
+ current,
380
+ command.type === "autotune-show"
381
+ ? { type: "autotune-set", values: {} }
382
+ : command,
383
+ track.instrument,
384
+ );
385
+ next = updateTrack(score, trackId, { autotune });
386
+ } catch (error) {
387
+ if (
388
+ error instanceof ScoreValidationError ||
389
+ error instanceof FxValidationError
390
+ )
391
+ return {
392
+ ok: false,
393
+ message: `autotune · ${error.message.replace(/^autotune: /, "")}`,
394
+ };
395
+ throw error;
396
+ }
397
+ const inert =
398
+ !track.sampler && track.clips === undefined
399
+ ? ` · ${trackId} has no audio yet (it tunes clips and sampler voices)`
400
+ : "";
401
+ const keyHint =
402
+ (autotune.to ?? "scale") === "scale" &&
403
+ autotune.key === undefined &&
404
+ !score.key &&
405
+ !(autotune.preset === "guided" || autotune.preset === "locked")
406
+ ? " · no song key, so chromatic (/scale or autotune key …)"
407
+ : "";
408
+ const guide =
409
+ resolveAutotune(autotune).to === "notes" &&
410
+ autotune.from === undefined &&
411
+ !hasGuideNotes(score, trackId)
412
+ ? noGuideHint(score, trackId, autotune.preset)
413
+ : "";
414
+ return {
415
+ ok: true,
416
+ message: `autotune · ${describeAutotune(autotune)}${keyHint}${guide}${inert}${autotuneEngineNote()}`,
417
+ next,
418
+ kind: "score.autotune",
419
+ payload: { trackId, autotune },
420
+ };
421
+ }