@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
@@ -54,12 +54,13 @@ export function parsePatternCommand(
54
54
  const match = command
55
55
  .trim()
56
56
  .match(
57
- /^\/patterns?(?:\s+([a-z0-9][\w-]{0,40}))?(?:\s+(keep-tempo|keep|tempo))?\s*$/i,
57
+ /^\/?(?:patterns?|grooves?)(?:\s+([a-z0-9][\w-]{0,40}))?(?:\s+(keep-tempo|keep|tempo))?\s*$/i,
58
58
  );
59
59
  if (!match) return undefined;
60
60
  const name = match[1]?.toLowerCase();
61
61
  if (!name) return { kind: "browse" };
62
- if (name === "list" || name === "ls") return { kind: "list" };
62
+ if (name === "list" || name === "ls" || name === "presets")
63
+ return { kind: "list" };
63
64
  const flag = match[2]?.toLowerCase();
64
65
  return {
65
66
  kind: "apply",
@@ -13,6 +13,7 @@
13
13
  * score and the session event, leaving persistence to the caller.
14
14
  */
15
15
  import {
16
+ AUTOMATION_PARAMETERS,
16
17
  automationPoints,
17
18
  automationRange,
18
19
  SCORE_LIMITS,
@@ -40,7 +41,13 @@ export function parseLane(name: string): AutomationParameter | undefined {
40
41
  const lower = name.toLowerCase();
41
42
  if (lower === "volume" || lower === "vol") return "volume";
42
43
  if (lower === "pan") return "pan";
43
- return parseEffectLane(lower);
44
+ // Lanes are typed in any case: `synth-pitchjump` is `synth-pitchJump`.
45
+ return (
46
+ parseEffectLane(lower) ??
47
+ parseEffectLane(
48
+ AUTOMATION_PARAMETERS.find((lane) => lane.toLowerCase() === lower) ?? "",
49
+ )
50
+ );
44
51
  }
45
52
 
46
53
  const NUMBER = /^-?\d+(?:\.\d+)?$/;
@@ -69,7 +76,7 @@ export function parseEditCommand(prompt: string): EditCommand | undefined {
69
76
  const parsed = parseKey(value);
70
77
  return parsed ? { type: "key", key: keyName(parsed) } : undefined;
71
78
  }
72
- const points = text.match(/^automate ([a-z-]+) points((?: \S+)+)$/i);
79
+ const points = text.match(/^automate ([a-z0-9-]+) points((?: \S+)+)$/i);
73
80
  if (points) {
74
81
  const parameter = parseLane(points[1]!);
75
82
  if (!parameter) return undefined;
@@ -89,7 +96,7 @@ export function parseEditCommand(prompt: string): EditCommand | undefined {
89
96
  return undefined;
90
97
  return { type: "automation-points", parameter, points: parsed };
91
98
  }
92
- const remove = text.match(/^automate ([a-z-]+) remove (\d+(?:\.\d+)?)$/i);
99
+ const remove = text.match(/^automate ([a-z0-9-]+) remove (\d+(?:\.\d+)?)$/i);
93
100
  if (remove) {
94
101
  const parameter = parseLane(remove[1]!);
95
102
  return parameter
@@ -174,7 +181,7 @@ export function applyEditCommand(
174
181
  if (points.length === current.length)
175
182
  return {
176
183
  ok: false,
177
- message: `no ${command.parameter} point at beat ${command.beat}`,
184
+ message: `no ${command.parameter} point at beat ${command.beat} · the ${command.parameter} lane shows them · help automate`,
178
185
  };
179
186
  }
180
187
  return {
@@ -536,7 +536,7 @@ export function describeTarget(target: NoteTarget): string {
536
536
  export function patchNotes(
537
537
  score: TrackScore,
538
538
  noteIds: ReadonlySet<string>,
539
- patch: NoteExpressionPatch,
539
+ patch: NoteExpressionPatch & Readonly<{ vowel?: string | null }>,
540
540
  ): TrackScore {
541
541
  if (noteIds.size === 0) return score;
542
542
  return new TrackScore({
@@ -0,0 +1,221 @@
1
+ /**
2
+ * `/formant` and `/vowel` (0.7): short forms of `fx formant` and `fx vowel`
3
+ * on the focused track. Both build the same `FxCommand` as `fx`, so the
4
+ * edit, validation and undo step are the fx command's.
5
+ *
6
+ * formant show the shift and mix
7
+ * formant <-12..12> [mix] shift the formants (pitch stays): -4 deeper
8
+ * formant <preset> deep giant bright tiny
9
+ * formant shift <st> mix <0..1> spelled out
10
+ * formant on | off | reset turn it on | remove it | back to 0 st, mix 1
11
+ *
12
+ * vowel show the vowel filter
13
+ * vowel <v> [<to> [<morph>]] a, or a morphing toward o (morph 0.5);
14
+ * keeps the mix you set
15
+ * vowel <preset> ee (also a, o)
16
+ * vowel to <v> | morph <0..1> change one part of the morph
17
+ * vowel to off drop the morph target (keeps vowel and mix)
18
+ * vowel mix <0..1> | off
19
+ *
20
+ * `/vocal formant …` runs the same `/formant` grammar.
21
+ */
22
+ import { FX_PRESETS, VOWEL_VALUES } from "../../core/fx.ts";
23
+ import type { TrackScore } from "../../core/score.ts";
24
+ import {
25
+ applyFxCommand,
26
+ effectValues,
27
+ FORMANT_VOWEL_HINT,
28
+ parseFxCommand,
29
+ type FxCommand,
30
+ type FxResult,
31
+ } from "./fx.ts";
32
+
33
+ export const FORMANT_USAGE =
34
+ "formant <-12..12> [mix] | <deep|giant|bright|tiny> | on | off";
35
+ export const VOWEL_USAGE =
36
+ "vowel <a|e|i|o|u…> [<to> [<morph 0..1>]] | ee | to off | off";
37
+
38
+ export type FormantCommand =
39
+ | Readonly<{ type: "show"; effect: "formant" | "vowel" }>
40
+ | Readonly<{ type: "fx"; command: FxCommand }>
41
+ | Readonly<{ type: "error"; message: string }>;
42
+
43
+ const isVowel = (word: string): boolean =>
44
+ (VOWEL_VALUES as readonly string[]).includes(word);
45
+
46
+ /** A number word such as `-3`, `+2.5` or `-4st`. */
47
+ function numberWord(word: string): number | undefined {
48
+ const match = word.match(/^([+-]?\d+(?:\.\d+)?|[+-]?\.\d+)(?:st)?$/i);
49
+ return match ? Number(match[1]) : undefined;
50
+ }
51
+
52
+ /** Parse `/formant …` (the slash is optional only with arguments). */
53
+ export function parseFormantCommand(
54
+ command: string,
55
+ ): FormantCommand | undefined {
56
+ const match = command.trim().match(/^\/formant(?:\s+(.*))?$/i);
57
+ if (!match) return undefined;
58
+ return formantArgs(match[1] ?? "");
59
+ }
60
+
61
+ /** The words after `/formant` (shared with `/vocal formant`). */
62
+ export function formantArgs(args: string): FormantCommand {
63
+ const words = args.trim().toLowerCase().split(/\s+/).filter(Boolean);
64
+ if (words.length === 0) return { type: "show", effect: "formant" };
65
+ const [first, ...rest] = words as [string, ...string[]];
66
+ if (rest.length === 0 && (first === "off" || first === "reset"))
67
+ return {
68
+ type: "fx",
69
+ command: {
70
+ type: first === "off" ? "fx-off" : "fx-reset",
71
+ effect: "formant",
72
+ },
73
+ };
74
+ // `on` is also a nasal vowel word; as the only word it is the toggle.
75
+ if (rest.length === 0 && ["on", "true", "yes"].includes(first))
76
+ return { type: "fx", command: { type: "fx-on", effect: "formant" } };
77
+ if (rest.length === 0 && FX_PRESETS.formant?.[first])
78
+ return {
79
+ type: "fx",
80
+ command: { type: "fx-preset", effect: "formant", preset: first },
81
+ };
82
+ const shift = numberWord(first);
83
+ // `-4`, `-4 0.5` or `-4 mix 0.5`.
84
+ const mixWords = rest[0] === "mix" ? rest.slice(1) : rest;
85
+ if (shift !== undefined && mixWords.length <= 1 && rest.length <= 2) {
86
+ const mix = mixWords[0] === undefined ? undefined : numberWord(mixWords[0]);
87
+ if (rest.length > 0 && mix === undefined)
88
+ return { type: "error", message: `usage: /${FORMANT_USAGE}` };
89
+ return {
90
+ type: "fx",
91
+ command: {
92
+ type: "fx-set",
93
+ effect: "formant",
94
+ values: { shift, ...(mix === undefined ? {} : { mix }) },
95
+ },
96
+ };
97
+ }
98
+ if (isVowel(first) || words.includes("vowel"))
99
+ return {
100
+ type: "error",
101
+ message: `${FORMANT_VOWEL_HINT} · /vowel ${isVowel(first) ? first : "a"}`,
102
+ };
103
+ const generic = parseFxCommand(`fx formant ${words.join(" ")}`);
104
+ if (generic) return { type: "fx", command: generic };
105
+ return { type: "error", message: `usage: /${FORMANT_USAGE}` };
106
+ }
107
+
108
+ /** Parse `/vowel …`. */
109
+ export function parseVowelCommand(command: string): FormantCommand | undefined {
110
+ const match = command.trim().match(/^\/vowel(?:\s+(.*))?$/i);
111
+ if (!match) return undefined;
112
+ const words = (match[1] ?? "")
113
+ .trim()
114
+ .toLowerCase()
115
+ .split(/\s+/)
116
+ .filter(Boolean);
117
+ if (words.length === 0) return { type: "show", effect: "vowel" };
118
+ const [first, second, third] = words;
119
+ if (words.length === 1 && (first === "off" || first === "reset"))
120
+ return {
121
+ type: "fx",
122
+ command: {
123
+ type: first === "off" ? "fx-off" : "fx-reset",
124
+ effect: "vowel",
125
+ },
126
+ };
127
+ if (words.length === 2 && first === "to" && second === "off")
128
+ return {
129
+ type: "fx",
130
+ command: { type: "fx-replace", effect: "vowel", values: {} },
131
+ };
132
+ if (
133
+ words.length === 1 &&
134
+ !isVowel(first!) &&
135
+ FX_PRESETS.vowel?.[first!] !== undefined
136
+ )
137
+ return {
138
+ type: "fx",
139
+ command: { type: "fx-preset", effect: "vowel", preset: first! },
140
+ };
141
+ if (isVowel(first!) && words.length <= 3) {
142
+ // `mix` is filled from the stored vowel when the command is applied.
143
+ if (second === undefined)
144
+ return {
145
+ type: "fx",
146
+ command: {
147
+ type: "fx-replace",
148
+ effect: "vowel",
149
+ values: { vowel: first! },
150
+ },
151
+ };
152
+ const morph = third === undefined ? 0.5 : numberWord(third);
153
+ if (!isVowel(second) || morph === undefined)
154
+ return { type: "error", message: `usage: /${VOWEL_USAGE}` };
155
+ return {
156
+ type: "fx",
157
+ command: {
158
+ type: "fx-replace",
159
+ effect: "vowel",
160
+ values: { vowel: first!, to: second, morph },
161
+ },
162
+ };
163
+ }
164
+ const generic = parseFxCommand(`fx vowel ${words.join(" ")}`);
165
+ if (generic && generic.type === "fx-set")
166
+ return {
167
+ type: "fx",
168
+ // `/vowel to o` alone starts halfway so the morph is audible.
169
+ command:
170
+ generic.values.to !== undefined && generic.values.morph === undefined
171
+ ? { ...generic, values: { morph: 0.5, ...generic.values } }
172
+ : generic,
173
+ };
174
+ return { type: "error", message: `usage: /${VOWEL_USAGE}` };
175
+ }
176
+
177
+ /** Run a parsed `/formant` or `/vowel` on the focused track. */
178
+ export function applyFormantCommand(
179
+ score: TrackScore,
180
+ trackId: string,
181
+ command: FormantCommand,
182
+ ): FxResult {
183
+ if (command.type === "error") return { ok: false, message: command.message };
184
+ if (command.type === "fx") {
185
+ // `/vowel <v> [...]` replaces the vowel and its morph but keeps the
186
+ // stored mix; `/vowel to off` keeps vowel and mix and drops the morph.
187
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
188
+ const fx = command.command;
189
+ if (fx.type === "fx-replace" && fx.effect === "vowel") {
190
+ const current = effectValues(track, "vowel");
191
+ const { vowel: vowelWord, ...morphPart } = fx.values;
192
+ if (vowelWord === undefined && !current)
193
+ return { ok: true, message: "vowel · off" };
194
+ return applyFxCommand(score, trackId, {
195
+ ...fx,
196
+ values: {
197
+ vowel: vowelWord ?? current?.vowel ?? "a",
198
+ mix: current?.mix ?? 1,
199
+ ...morphPart,
200
+ },
201
+ });
202
+ }
203
+ return applyFxCommand(score, trackId, fx);
204
+ }
205
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
206
+ if (!track) return { ok: false, message: `no track · ${trackId}` };
207
+ const values = effectValues(track, command.effect);
208
+ if (command.effect === "formant")
209
+ return {
210
+ ok: true,
211
+ message: values
212
+ ? `formant · shift ${values.shift} st · mix ${values.mix}`
213
+ : `formant · off · /${FORMANT_USAGE}`,
214
+ };
215
+ return {
216
+ ok: true,
217
+ message: values
218
+ ? `vowel · ${values.vowel}${values.to !== undefined ? ` → ${values.to} · morph ${values.morph ?? 0}` : ""} · mix ${values.mix}`
219
+ : `vowel · off · /${VOWEL_USAGE}`,
220
+ };
221
+ }
@@ -19,7 +19,7 @@
19
19
  * on/off/true/false; enums take one of their values. Each command is one
20
20
  * `updateTrack` revision and one undo step.
21
21
  */
22
- import { nearestWord } from "../audio/instrument-check.ts";
22
+ import { nearest } from "./nearest.ts";
23
23
  import {
24
24
  EFFECT_NAMES,
25
25
  FX_PRESETS,
@@ -29,6 +29,7 @@ import {
29
29
  isEffectName,
30
30
  normalizeParam,
31
31
  FxValidationError,
32
+ VOWEL_VALUES,
32
33
  type EffectName,
33
34
  type FxName,
34
35
  type FxValues,
@@ -57,37 +58,69 @@ export type FxCommand =
57
58
  type: "fx-set";
58
59
  effect: EffectName;
59
60
  values: Readonly<Record<string, number | string | boolean>>;
61
+ }
62
+ /** Store exactly these values (`/vowel a` drops a previous morph). */
63
+ | {
64
+ type: "fx-replace";
65
+ effect: EffectName;
66
+ values: Readonly<Record<string, number | string | boolean>>;
60
67
  };
61
68
 
62
- const EFFECT_ALIASES: Readonly<Record<string, EffectName>> = Object.freeze({
63
- lpf: "filter",
64
- hpf: "filter",
65
- bpf: "filter",
66
- dj: "djf",
67
- "dj-filter": "djf",
68
- "auto-filter": "autofilter",
69
- autof: "autofilter",
70
- formant: "vowel",
71
- bitcrush: "crush",
72
- bitcrusher: "crush",
73
- coarse: "crush",
74
- distortion: "distort",
75
- dist: "distort",
76
- drive: "distort",
77
- shape: "distort",
78
- trem: "tremolo",
79
- comp: "compressor",
80
- compress: "compressor",
81
- rotary: "leslie",
82
- gain: "postgain",
83
- echo: "delay",
84
- bus: "orbit",
85
- o: "orbit",
86
- sidechain: "duck",
87
- duckorbit: "duck",
88
- room: "reverb",
89
- verb: "reverb",
90
- });
69
+ export const EFFECT_ALIASES: Readonly<Record<string, EffectName>> =
70
+ Object.freeze({
71
+ lpf: "filter",
72
+ hpf: "filter",
73
+ bpf: "filter",
74
+ dj: "djf",
75
+ "dj-filter": "djf",
76
+ "auto-filter": "autofilter",
77
+ autof: "autofilter",
78
+ bitcrush: "crush",
79
+ bitcrusher: "crush",
80
+ coarse: "crush",
81
+ distortion: "distort",
82
+ dist: "distort",
83
+ drive: "distort",
84
+ shape: "distort",
85
+ trem: "tremolo",
86
+ comp: "compressor",
87
+ compress: "compressor",
88
+ rotary: "leslie",
89
+ gain: "postgain",
90
+ echo: "delay",
91
+ bus: "orbit",
92
+ o: "orbit",
93
+ sidechain: "duck",
94
+ duckorbit: "duck",
95
+ room: "reverb",
96
+ verb: "reverb",
97
+ });
98
+
99
+ /**
100
+ * 0.7: `formant` was an alias of the vowel filter; it now shifts formants.
101
+ * A vowel given to it gets this answer instead of a silent change.
102
+ */
103
+ export const FORMANT_FX_USAGE =
104
+ "formant · shift -12..12 st, mix 0..1 · presets deep giant bright tiny";
105
+
106
+ /** `/vowel morph`, `fx vowel morph` and set_fx all refuse a morph with no target. */
107
+ export const VOWEL_MORPH_NEEDS_TO =
108
+ "set a target first: /vowel a o 0.5 or /vowel to o";
109
+
110
+ export const FORMANT_VOWEL_HINT =
111
+ "formant now shifts formants at constant pitch; the vowel filter is `vowel`";
112
+
113
+ /** Whether `fx formant …` words or set_fx params name a vowel. */
114
+ export function formantGotVowel(
115
+ words: readonly (string | number | boolean)[],
116
+ ): boolean {
117
+ return words.some(
118
+ (word) =>
119
+ typeof word === "string" &&
120
+ (word.toLowerCase() === "vowel" ||
121
+ (VOWEL_VALUES as readonly string[]).includes(word.toLowerCase())),
122
+ );
123
+ }
91
124
 
92
125
  export function parseEffectName(name: string): EffectName | undefined {
93
126
  const lower = name.toLowerCase();
@@ -134,10 +167,25 @@ export function parseParamValue(
134
167
  }
135
168
  }
136
169
 
170
+ /** The effects people reach for first, for the empty `fx` read. */
171
+ const COMMON_FIRST: readonly string[] = [
172
+ "reverb",
173
+ "delay",
174
+ "filter",
175
+ "distort",
176
+ "chorus",
177
+ "compressor",
178
+ ];
179
+
180
+ const FX_LIST_WORDS: readonly string[] = ["list", "ls", "presets"];
181
+
137
182
  export function parseFxCommand(prompt: string): FxCommand | undefined {
138
183
  const words = prompt.trim().split(/\s+/);
139
184
  if (words[0]?.toLowerCase() !== "fx") return undefined;
140
185
  if (words.length === 1) return { type: "fx-list" };
186
+ // `fx list`, `fx ls`, `fx presets`: the same read as bare `fx`.
187
+ if (words.length === 2 && FX_LIST_WORDS.includes(words[1]!.toLowerCase()))
188
+ return { type: "fx-list" };
141
189
  if (prompt.length > 1_024) return undefined;
142
190
  const irWord = (word: string | undefined) =>
143
191
  word !== undefined && ["ir", "iresponse"].includes(word.toLowerCase());
@@ -203,9 +251,16 @@ export function unknownFxMessage(prompt: string): string | undefined {
203
251
  const words = prompt.trim().split(/\s+/);
204
252
  if (words[0]?.toLowerCase() !== "fx" || words.length < 2) return undefined;
205
253
  const name = words[1]!.toLowerCase();
254
+ if (name === "formant" && formantGotVowel(words.slice(2)))
255
+ return FORMANT_VOWEL_HINT;
256
+ // A new numeric-first effect: out-of-range values and preset typos get its
257
+ // ranges and presets instead of the generic unknown-command path.
258
+ if (name === "formant" && words.length <= 6 && !parseFxCommand(prompt))
259
+ return FORMANT_FX_USAGE;
206
260
  if (parseEffectName(name) || ["ir", "iresponse", "amp"].includes(name))
207
261
  return undefined;
208
- const near = nearestWord(name, [
262
+ if (words.length === 2 && FX_LIST_WORDS.includes(name)) return undefined;
263
+ const near = nearest(name, [
209
264
  ...EFFECT_NAMES,
210
265
  ...Object.keys(EFFECT_ALIASES),
211
266
  "reverb",
@@ -242,6 +297,11 @@ export function effectDefaults(effect: EffectName): FxValues {
242
297
  delete out.sag;
243
298
  delete out.gate;
244
299
  }
300
+ // The vowel morph stays absent until set: `fx vowel on` stores 0.6 data.
301
+ if (effect === "vowel") {
302
+ delete out.to;
303
+ delete out.morph;
304
+ }
245
305
  return out;
246
306
  }
247
307
 
@@ -304,7 +364,7 @@ export function applyFxCommand(
304
364
  ok: true,
305
365
  message: on.length
306
366
  ? `fx · ${on.map((effect) => `${effect} (${describe(effect, effectValues(track, effect)!)})`).join(" → ")}`
307
- : `fx · none · chain ${FX_CHAIN.join(" → ")}`,
367
+ : `fx · none · ${COMMON_FIRST.join(" ")} … · fx list`,
308
368
  };
309
369
  }
310
370
  if (command.type === "fx-ir")
@@ -324,7 +384,16 @@ export function applyFxCommand(
324
384
  ...effectDefaults(effect),
325
385
  ...FX_PRESETS[effect]![command.preset],
326
386
  };
387
+ else if (command.type === "fx-replace") values = { ...command.values };
327
388
  else values = { ...(current ?? effectDefaults(effect)), ...command.values };
389
+ if (
390
+ values &&
391
+ effect === "vowel" &&
392
+ command.type === "fx-set" &&
393
+ command.values.morph !== undefined &&
394
+ values.to === undefined
395
+ )
396
+ return { ok: false, message: `vowel · ${VOWEL_MORPH_NEEDS_TO}` };
328
397
  if (values && effect === "delay" && values.time === 0) {
329
398
  const { time: _time, ...rest } = values;
330
399
  values = rest;