@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
@@ -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 {
@@ -8,6 +8,7 @@
8
8
  * glide <ms>|off <target> portamento into each note
9
9
  * glide <ms>|0|off [legato|mono|poly] the track's glide (`glide mono`)
10
10
  * pedal <beat>-<beat>…|bars [<bar>-<bar>]|down|half|up <beat>|off
11
+ * pedal soft|sost <same forms> una corda and sostenuto (0.6.1)
11
12
  * velcurve linear|soft|hard|fixed [<v>]
12
13
  * humanize <ms> [<vel%> [<len%>]] [seed <n>]|on|off|reseed|seed <n>
13
14
  * humanize <ms> [<vel%> [<len%>]]|exact|off <target> per-note amounts
@@ -32,6 +33,7 @@ import {
32
33
  normalizeBend,
33
34
  normalizeHumanize,
34
35
  normalizePedal,
36
+ SOSTENUTO_STATES,
35
37
  normalizeTrackGlide,
36
38
  normalizeVelocityCurve,
37
39
  normalizeVibrato,
@@ -53,6 +55,7 @@ import {
53
55
  type Note,
54
56
  type Track,
55
57
  } from "../../core/score.ts";
58
+ import { softPedalNote } from "../../core/keys.ts";
56
59
  import { barStartTick, loopTicksOf } from "../../core/tempo.ts";
57
60
 
58
61
  export type NoteTarget =
@@ -73,11 +76,15 @@ export type ExpressionCommand =
73
76
  | { type: "vibrato"; vibrato: NoteVibrato | null; target: NoteTarget }
74
77
  | { type: "note-glide"; glide: number | null; target: NoteTarget }
75
78
  | { type: "track-glide"; time?: number | null; mode?: GlideMode }
76
- | { type: "pedal-spans"; spans: readonly { from: number; to: number }[] }
77
- | { type: "pedal-bars"; from?: number; to?: number }
78
- | { type: "pedal-list" }
79
- | { type: "pedal-event"; state: PedalState; beat: number }
80
- | { type: "pedal-off" }
79
+ | {
80
+ type: "pedal-spans";
81
+ spans: readonly { from: number; to: number }[];
82
+ lane?: PedalLane;
83
+ }
84
+ | { type: "pedal-bars"; from?: number; to?: number; lane?: PedalLane }
85
+ | { type: "pedal-list"; lane?: PedalLane }
86
+ | { type: "pedal-event"; state: PedalState; beat: number; lane?: PedalLane }
87
+ | { type: "pedal-off"; lane?: PedalLane }
81
88
  | { type: "velcurve"; curve: VelocityCurveName; fixed?: number }
82
89
  | {
83
90
  type: "humanize";
@@ -299,7 +306,31 @@ function nearWord(text: string, word: string): boolean {
299
306
  return previous[word.length]! <= 2;
300
307
  }
301
308
 
309
+ /**
310
+ * Which pedal a `pedal` command edits (0.6.1): absent is the sustain
311
+ * pedal, `soft` the una corda (Track.softPedal), `sost` the sostenuto.
312
+ */
313
+ export type PedalLane = "soft" | "sost";
314
+
315
+ const PEDAL_LANES: Readonly<Record<string, PedalLane>> = {
316
+ soft: "soft",
317
+ unacorda: "soft",
318
+ sost: "sost",
319
+ sostenuto: "sost",
320
+ };
321
+
302
322
  function parsePedal(words: string[]): ExpressionCommand | undefined {
323
+ const lane = words[0] ? PEDAL_LANES[words[0]] : undefined;
324
+ if (lane) {
325
+ const command = parsePedalLane(words.slice(1));
326
+ return command && command.type.startsWith("pedal-")
327
+ ? ({ ...command, lane } as ExpressionCommand)
328
+ : undefined;
329
+ }
330
+ return parsePedalLane(words);
331
+ }
332
+
333
+ function parsePedalLane(words: string[]): ExpressionCommand | undefined {
303
334
  const [first, ...rest] = words;
304
335
  if (!first) return { type: "pedal-list" };
305
336
  if (OFF.test(first) && rest.length === 0) return { type: "pedal-off" };
@@ -505,7 +536,7 @@ export function describeTarget(target: NoteTarget): string {
505
536
  export function patchNotes(
506
537
  score: TrackScore,
507
538
  noteIds: ReadonlySet<string>,
508
- patch: NoteExpressionPatch,
539
+ patch: NoteExpressionPatch & Readonly<{ vowel?: string | null }>,
509
540
  ): TrackScore {
510
541
  if (noteIds.size === 0) return score;
511
542
  return new TrackScore({
@@ -564,6 +595,16 @@ export function describePerformance(score: TrackScore, track: Track): string {
564
595
  const parts = [
565
596
  `glide ${track.glide ? `${ms(track.glide.time)} ${track.glide.mode}` : "off"}`,
566
597
  `pedal ${track.pedal ? `${track.pedal.length} event${track.pedal.length === 1 ? "" : "s"}` : "off"}`,
598
+ ...(track.softPedal
599
+ ? [
600
+ `soft ${track.softPedal.length} event${track.softPedal.length === 1 ? "" : "s"}`,
601
+ ]
602
+ : []),
603
+ ...(track.sostenuto
604
+ ? [
605
+ `sost ${track.sostenuto.length} event${track.sostenuto.length === 1 ? "" : "s"}`,
606
+ ]
607
+ : []),
567
608
  `velcurve ${track.velocityCurve ? `${track.velocityCurve.curve}${track.velocityCurve.fixed !== undefined ? ` ${track.velocityCurve.fixed}` : ""}` : "linear"}`,
568
609
  `humanize ${describeHumanize(track.humanize)}`,
569
610
  ];
@@ -680,10 +721,24 @@ function mergePedal(
680
721
  current: readonly PedalEvent[] | undefined,
681
722
  added: readonly PedalEvent[],
682
723
  maxTick: number,
724
+ label?: string,
725
+ states?: readonly PedalState[],
683
726
  ): readonly PedalEvent[] | undefined {
684
727
  const byTick = new Map((current ?? []).map((event) => [event.tick, event]));
685
728
  for (const event of added) byTick.set(event.tick, event);
686
- return normalizePedal([...byTick.values()], maxTick);
729
+ return normalizePedal([...byTick.values()], maxTick, label, states);
730
+ }
731
+
732
+ /** The Track field, message label and allowed states of a pedal lane. */
733
+ function pedalLaneOf(lane: PedalLane | undefined): {
734
+ field: "pedal" | "softPedal" | "sostenuto";
735
+ label: string;
736
+ states?: readonly PedalState[];
737
+ } {
738
+ if (lane === "soft") return { field: "softPedal", label: "soft pedal" };
739
+ if (lane === "sost")
740
+ return { field: "sostenuto", label: "sostenuto", states: SOSTENUTO_STATES };
741
+ return { field: "pedal", label: "pedal" };
687
742
  }
688
743
 
689
744
  /** Apply `command` to the focused track `trackId`. */
@@ -771,11 +826,12 @@ export function applyExpressionCommand(
771
826
  );
772
827
  }
773
828
  case "pedal-list": {
774
- const events = track.pedal ?? [];
829
+ const { field, label } = pedalLaneOf(command.lane);
830
+ const events = track[field] ?? [];
775
831
  if (events.length === 0)
776
832
  return {
777
833
  ok: true,
778
- message: `pedal · none on ${trackId} · pedal bars to add`,
834
+ message: `${label} · none on ${trackId} · ${command.lane ? `pedal ${command.lane} 0-4` : "pedal bars"} to add`,
779
835
  };
780
836
  const shown = events
781
837
  .slice(0, 12)
@@ -784,24 +840,45 @@ export function applyExpressionCommand(
784
840
  `${Math.round((event.tick / score.ticksPerBeat) * 1000) / 1000} ${event.state}`,
785
841
  );
786
842
  if (events.length > 12) shown.push(`+${events.length - 12} more`);
787
- return { ok: true, message: `pedal · ${shown.join(" · ")} · ${trackId}` };
843
+ return {
844
+ ok: true,
845
+ message: `${label} · ${shown.join(" · ")} · ${trackId}`,
846
+ };
847
+ }
848
+ case "pedal-off": {
849
+ const { field, label } = pedalLaneOf(command.lane);
850
+ return track[field]
851
+ ? trackResult(
852
+ score,
853
+ track,
854
+ { [field]: null },
855
+ `${label} · off · ${trackId}`,
856
+ )
857
+ : { ok: true, message: `${label} · already off · ${trackId}` };
788
858
  }
789
- case "pedal-off":
790
- return track.pedal
791
- ? trackResult(score, track, { pedal: null }, `pedal · off · ${trackId}`)
792
- : { ok: true, message: `pedal · already off · ${trackId}` };
793
859
  case "pedal-spans":
794
860
  case "pedal-bars":
795
861
  case "pedal-event": {
862
+ const { field, label, states } = pedalLaneOf(command.lane);
796
863
  let added: PedalEvent[];
797
864
  if (command.type === "pedal-bars") {
798
865
  const from = command.from ?? 1;
799
866
  if (from > score.bars)
800
867
  return {
801
868
  ok: false,
802
- message: `pedal · the loop has ${score.bars} bar${score.bars === 1 ? "" : "s"}`,
869
+ message: `${label} · the loop has ${score.bars} bar${score.bars === 1 ? "" : "s"}`,
803
870
  };
804
- added = barPedal(score, from, command.to ?? score.bars);
871
+ const to = Math.min(command.to ?? score.bars, score.bars);
872
+ // Soft and sostenuto hold through the bars (no re-pedalling).
873
+ added = command.lane
874
+ ? [
875
+ { tick: barStartTick(score, from - 1), state: "down" },
876
+ {
877
+ tick: Math.min(barStartTick(score, to), maxTick),
878
+ state: "up",
879
+ },
880
+ ]
881
+ : barPedal(score, from, to);
805
882
  } else if (command.type === "pedal-event") {
806
883
  added = [
807
884
  { tick: Math.round(command.beat * tpb), state: command.state },
@@ -815,7 +892,7 @@ export function applyExpressionCommand(
815
892
  if (added.some((event) => event.tick > maxTick))
816
893
  return {
817
894
  ok: false,
818
- message: `pedal · beats run 0..${maxTick / tpb} in this loop`,
895
+ message: `${label} · beats run 0..${maxTick / tpb} in this loop`,
819
896
  };
820
897
  let pedal: readonly PedalEvent[] | undefined;
821
898
  try {
@@ -825,11 +902,11 @@ export function applyExpressionCommand(
825
902
  command.type === "pedal-bars" &&
826
903
  command.from === undefined &&
827
904
  command.to === undefined
828
- ? normalizePedal(added, maxTick)
829
- : mergePedal(track.pedal, added, maxTick);
905
+ ? normalizePedal(added, maxTick, field, states)
906
+ : mergePedal(track[field], added, maxTick, field, states);
830
907
  } catch (error) {
831
908
  if (error instanceof ExpressionValidationError)
832
- return { ok: false, message: `pedal · ${error.message}` };
909
+ return { ok: false, message: `${label} · ${error.message}` };
833
910
  throw error;
834
911
  }
835
912
  const downs = (pedal ?? []).filter((event) => event.state === "down");
@@ -840,11 +917,15 @@ export function applyExpressionCommand(
840
917
  : []),
841
918
  ...(halves.length > 0 ? [`${halves.length} half`] : []),
842
919
  ].join(", ");
920
+ // Una corda is stored anyway (an instrument change may follow), but
921
+ // say when nothing will sound different.
922
+ const silent =
923
+ field === "softPedal" && pedal ? softPedalNote(track) : undefined;
843
924
  return trackResult(
844
925
  score,
845
926
  track,
846
- { pedal: pedal ?? null },
847
- `pedal · ${pedal?.length ?? 0} event${pedal?.length === 1 ? "" : "s"} (${held}) · ${trackId}`,
927
+ { [field]: pedal ?? null },
928
+ `${label} · ${pedal?.length ?? 0} event${pedal?.length === 1 ? "" : "s"} (${held}) · ${trackId}${silent ? ` · no effect: ${silent}` : ""}`,
848
929
  );
849
930
  }
850
931
  case "velcurve": {
@@ -933,7 +1014,7 @@ export const EXPRESSION_USAGE = Object.freeze({
933
1014
  glide:
934
1015
  "glide <ms>|<s>s|off [legato|mono|poly] (track) · glide <ms>|off <target> (notes) · glide 60 mono · SDK glide: 0.06 (s)",
935
1016
  pedal:
936
- "pedal <beat>-<beat>…|bars [<bar>-<bar>]|down|half|up <beat>|off · pedal 0-3.5 4-7.5 · pedal bars",
1017
+ "pedal [soft|sost] <beat>-<beat>…|bars [<bar>-<bar>]|down|half|up <beat>|off · pedal 0-3.5 4-7.5 · pedal bars · pedal soft 0-8 (una corda) · pedal sost 0-4 (holds keys down at 0)",
937
1018
  velcurve:
938
1019
  "velcurve linear|soft|hard|fixed [<v 0..1>] · velcurve soft · velcurve fixed 0.8",
939
1020
  humanize:
@@ -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
+ }