@hraness/dawg 0.4.0 → 0.5.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 (116) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/DAWG.md +300 -35
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +37 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/loop.ts +23 -0
  8. package/core/master.ts +455 -0
  9. package/core/midi.ts +452 -0
  10. package/core/rhythm.ts +7 -2
  11. package/core/score.ts +536 -56
  12. package/core/sdk/eval-child.ts +38 -3
  13. package/core/sdk/print.ts +445 -14
  14. package/core/sdk/v1.ts +1709 -23
  15. package/core/sections.ts +2046 -0
  16. package/core/synth.ts +11 -1
  17. package/core/tempo.ts +1318 -0
  18. package/core/tuning.ts +1180 -0
  19. package/guides/audition.md +26 -0
  20. package/guides/automation.md +26 -0
  21. package/guides/chords.md +28 -0
  22. package/guides/effects.md +27 -0
  23. package/guides/faders.md +29 -0
  24. package/guides/files.md +28 -0
  25. package/guides/getting-started.md +26 -0
  26. package/guides/index.ts +75 -0
  27. package/guides/keys.md +27 -0
  28. package/guides/media.md +27 -0
  29. package/guides/mix.md +23 -0
  30. package/guides/music.md +14 -0
  31. package/guides/notes.md +26 -0
  32. package/guides/performance.md +26 -0
  33. package/guides/play.md +24 -0
  34. package/guides/project.md +13 -0
  35. package/guides/providers.md +25 -0
  36. package/guides/rhythm.md +26 -0
  37. package/guides/sessions.md +21 -0
  38. package/guides/sound.md +15 -0
  39. package/guides/sounds.md +25 -0
  40. package/guides/tempo.md +26 -0
  41. package/guides/tracks.md +25 -0
  42. package/guides/web-search.md +21 -0
  43. package/package.json +3 -1
  44. package/src/agent/agent.ts +2 -0
  45. package/src/agent/brief.ts +77 -3
  46. package/src/agent/chord-tools.ts +9 -1
  47. package/src/agent/expression-tools.ts +336 -0
  48. package/src/agent/master-tools.ts +299 -0
  49. package/src/agent/models.ts +4 -4
  50. package/src/agent/ops.ts +29 -4
  51. package/src/agent/planner.ts +45 -2
  52. package/src/agent/preview-tool.ts +21 -3
  53. package/src/agent/section-tools.ts +411 -0
  54. package/src/agent/time-tools.ts +290 -0
  55. package/src/agent/tools.ts +36 -2
  56. package/src/agent/tuning-tools.ts +301 -0
  57. package/src/agent/xcb-agent.ts +2 -0
  58. package/src/audio/arrange.ts +471 -0
  59. package/src/audio/audition.ts +16 -3
  60. package/src/audio/click.ts +113 -1
  61. package/src/audio/clock.ts +71 -5
  62. package/src/audio/effects/bus.ts +5 -4
  63. package/src/audio/effects/chain.ts +12 -2
  64. package/src/audio/effects/common.ts +30 -1
  65. package/src/audio/effects/convolution.ts +43 -5
  66. package/src/audio/effects/dynamics.ts +2 -1
  67. package/src/audio/effects/filter.ts +5 -3
  68. package/src/audio/effects/modulation.ts +5 -1
  69. package/src/audio/effects/space.ts +197 -50
  70. package/src/audio/engine.ts +175 -20
  71. package/src/audio/live.ts +33 -6
  72. package/src/audio/loudness.ts +551 -0
  73. package/src/audio/master.ts +660 -0
  74. package/src/audio/measure-worker.ts +45 -0
  75. package/src/audio/measure.ts +110 -0
  76. package/src/audio/player.ts +11 -6
  77. package/src/audio/preview.ts +87 -16
  78. package/src/audio/render-worker.ts +6 -1
  79. package/src/audio/renderer.ts +7 -1
  80. package/src/audio/sampler.ts +108 -19
  81. package/src/audio/synth/voice.ts +60 -13
  82. package/src/audio/synth/zzfx.ts +10 -4
  83. package/src/audio/warp.ts +86 -0
  84. package/src/audio/wav.ts +448 -59
  85. package/src/commands/arrange.ts +949 -0
  86. package/src/commands/expression.ts +934 -0
  87. package/src/commands/help.ts +281 -24
  88. package/src/commands/master.ts +361 -0
  89. package/src/commands/music.ts +6 -1
  90. package/src/commands/synth.ts +11 -1
  91. package/src/commands/time.ts +964 -0
  92. package/src/commands/tuning.ts +490 -0
  93. package/src/main.ts +826 -47
  94. package/src/render.ts +109 -7
  95. package/src/session/daemon.ts +18 -8
  96. package/src/session/naming.ts +8 -1
  97. package/src/session/rebase.ts +21 -0
  98. package/src/tui/arrange-menu.ts +390 -0
  99. package/src/tui/audition.ts +58 -5
  100. package/src/tui/euclid.ts +50 -6
  101. package/src/tui/fader.ts +409 -0
  102. package/src/tui/menu-time.ts +401 -0
  103. package/src/tui/menu.ts +656 -16
  104. package/src/tui/performance-menu.ts +235 -0
  105. package/src/tui/play-chords.ts +95 -1
  106. package/src/tui/play-mode.ts +150 -6
  107. package/src/tui/play-session.ts +414 -41
  108. package/tui/app.ts +262 -13
  109. package/tui/arrange-strip.ts +174 -0
  110. package/tui/drawer.ts +478 -0
  111. package/tui/grammar.ts +79 -12
  112. package/tui/guide.ts +351 -0
  113. package/tui/highway.ts +87 -4
  114. package/tui/hits.ts +68 -0
  115. package/tui/input.ts +7 -0
  116. package/tui/keys.ts +72 -0
@@ -0,0 +1,934 @@
1
+ /**
2
+ * Expression and performance commands. Note settings apply to a target on
3
+ * the focused track; track settings shape every note it plays.
4
+ *
5
+ * art <articulation>|off [target] staccato legato accent tenuto marcato ghost
6
+ * bend <cents>|scoop|fall|doit|<at:cents>…|off [target]
7
+ * vibrato <rate> <depth> [<delay>]|off [target]
8
+ * glide <ms>|off <target> portamento into each note
9
+ * glide <ms>|0|off [legato|mono|poly] the track's glide (`glide mono`)
10
+ * pedal <beat>-<beat>…|bars [<bar>-<bar>]|down|half|up <beat>|off
11
+ * velcurve linear|soft|hard|fixed [<v>]
12
+ * humanize <ms> [<vel%> [<len%>]] [seed <n>]|on|off|reseed|seed <n>
13
+ * humanize <ms> [<vel%> [<len%>]]|exact|off <target> per-note amounts
14
+ * expression what the track does
15
+ *
16
+ * A target is `all` (the default), `bar <n>`, `bars <a>-<b>` (1-based, notes
17
+ * that start there) or note ids. Beats are 0-based like `automate`. Times
18
+ * are milliseconds when bare (`glide 60`) or take a unit (`glide 60ms`,
19
+ * `glide 0.06s`); a bare fraction like `glide 0.06` is rejected as a unit
20
+ * slip. Parsing is pure; applying returns the next score and the session
21
+ * event.
22
+ */
23
+ import {
24
+ ARTICULATIONS,
25
+ ARTICULATION_EFFECTS,
26
+ DEFAULT_GLIDE_SECONDS,
27
+ EXPRESSION_LIMITS,
28
+ ExpressionValidationError,
29
+ GLIDE_MODES,
30
+ VELOCITY_CURVES,
31
+ normalizeArticulation,
32
+ normalizeBend,
33
+ normalizeHumanize,
34
+ normalizePedal,
35
+ normalizeTrackGlide,
36
+ normalizeVelocityCurve,
37
+ normalizeVibrato,
38
+ type Articulation,
39
+ type BendPoint,
40
+ type GlideMode,
41
+ type Humanize,
42
+ type NoteExpressionPatch,
43
+ type NoteHumanize,
44
+ type NoteVibrato,
45
+ type PedalEvent,
46
+ type PedalState,
47
+ type VelocityCurveName,
48
+ } from "../../core/expression.ts";
49
+ import {
50
+ ScoreValidationError,
51
+ TrackScore,
52
+ updateTrack,
53
+ type Note,
54
+ type Track,
55
+ } from "../../core/score.ts";
56
+ import { barStartTick, loopTicksOf } from "../../core/tempo.ts";
57
+
58
+ export type NoteTarget =
59
+ | { type: "all" }
60
+ /** The note added most recently on the track. */
61
+ | { type: "last" }
62
+ /** 1-based, inclusive. */
63
+ | { type: "bars"; from: number; to: number }
64
+ | { type: "ids"; ids: readonly string[] };
65
+
66
+ export type ExpressionCommand =
67
+ | {
68
+ type: "articulation";
69
+ articulation: Articulation | null;
70
+ target: NoteTarget;
71
+ }
72
+ | { type: "bend"; bend: readonly BendPoint[] | null; target: NoteTarget }
73
+ | { type: "vibrato"; vibrato: NoteVibrato | null; target: NoteTarget }
74
+ | { type: "note-glide"; glide: number | null; target: NoteTarget }
75
+ | { 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" }
81
+ | { type: "velcurve"; curve: VelocityCurveName; fixed?: number }
82
+ | {
83
+ type: "humanize";
84
+ timing: number;
85
+ velocity: number;
86
+ length: number;
87
+ seed?: number;
88
+ }
89
+ /** Per-note humanize: amounts replace the track's; `{}` keeps notes exact. */
90
+ | {
91
+ type: "note-humanize";
92
+ humanize: NoteHumanize | null;
93
+ target: NoteTarget;
94
+ }
95
+ | { type: "humanize-off" }
96
+ | { type: "humanize-seed"; seed?: number }
97
+ | { type: "show" }
98
+ /** Recognised but unusable (`glide 0.06`): fail with `message`. */
99
+ | { type: "invalid"; message: string };
100
+
101
+ /** Named bend shapes (jazz brass and vocal idioms), in cents. */
102
+ export const BEND_SHAPES: Readonly<Record<string, readonly BendPoint[]>> =
103
+ Object.freeze({
104
+ /** Scoop up into the note from a semitone below. */
105
+ scoop: [
106
+ { at: 0, cents: -100 },
107
+ { at: 0.2, cents: 0 },
108
+ ],
109
+ /** Fall off the end of the note. */
110
+ fall: [
111
+ { at: 0.6, cents: 0 },
112
+ { at: 1, cents: -500 },
113
+ ],
114
+ /** Rip up off the end of the note. */
115
+ doit: [
116
+ { at: 0.6, cents: 0 },
117
+ { at: 1, cents: 500 },
118
+ ],
119
+ });
120
+
121
+ /** Turned on without amounts: a light, audible feel. */
122
+ export const DEFAULT_HUMANIZE = Object.freeze({ timing: 8, velocity: 8 });
123
+
124
+ const ARTICULATION_ALIASES: Readonly<Record<string, Articulation>> = {
125
+ stac: "staccato",
126
+ stacc: "staccato",
127
+ leg: "legato",
128
+ acc: "accent",
129
+ ten: "tenuto",
130
+ marc: "marcato",
131
+ };
132
+
133
+ const NUMBER = /^[+-]?\d+(?:\.\d+)?$/;
134
+ const NOTE_ID = /^[A-Za-z0-9._:-]{1,128}$/;
135
+ const OFF = /^(off|none|clear|reset)$/i;
136
+
137
+ /** `60ms` → 0.06, `0.06`/`0.06s` → 0.06; undefined when not a time. */
138
+ export function parseSeconds(text: string): number | undefined {
139
+ const match = text.match(/^(\d+(?:\.\d+)?)(ms|s)?$/i);
140
+ if (!match) return undefined;
141
+ const value = Number(match[1]);
142
+ return match[2]?.toLowerCase() === "ms" ? value / 1000 : value;
143
+ }
144
+
145
+ /** Note target words; undefined when they are not a target. */
146
+ export function parseNoteTarget(
147
+ words: readonly string[],
148
+ ): NoteTarget | undefined {
149
+ if (words.length === 0) return { type: "all" };
150
+ const [first, second, ...rest] = words.map((word) => word.toLowerCase());
151
+ if (first === "all" && words.length === 1) return { type: "all" };
152
+ if (first === "last" && words.length === 1) return { type: "last" };
153
+ if ((first === "bar" || first === "bars") && second && rest.length === 0) {
154
+ const range = second.match(/^(\d+)(?:-(\d+))?$/);
155
+ if (!range) return undefined;
156
+ const from = Number(range[1]);
157
+ const to = Number(range[2] ?? range[1]);
158
+ return from >= 1 && to >= from ? { type: "bars", from, to } : undefined;
159
+ }
160
+ return words.every((word) => NOTE_ID.test(word))
161
+ ? { type: "ids", ids: [...new Set(words)] }
162
+ : undefined;
163
+ }
164
+
165
+ function articulationWord(word: string): Articulation | undefined {
166
+ const lower = word.toLowerCase();
167
+ const alias = ARTICULATION_ALIASES[lower];
168
+ if (alias) return alias;
169
+ try {
170
+ return normalizeArticulation(lower);
171
+ } catch {
172
+ return undefined;
173
+ }
174
+ }
175
+
176
+ function parseBend(words: string[]): ExpressionCommand | undefined {
177
+ const [first] = words;
178
+ if (!first) return undefined;
179
+ if (OFF.test(first)) {
180
+ const target = parseNoteTarget(words.slice(1));
181
+ return target ? { type: "bend", bend: null, target } : undefined;
182
+ }
183
+ const shape = BEND_SHAPES[first.toLowerCase()];
184
+ if (shape) {
185
+ const target = parseNoteTarget(words.slice(1));
186
+ return target ? { type: "bend", bend: shape, target } : undefined;
187
+ }
188
+ if (NUMBER.test(first)) {
189
+ const cents = Number(first);
190
+ if (Math.abs(cents) > EXPRESSION_LIMITS.maxBendCents) return undefined;
191
+ const target = parseNoteTarget(words.slice(1));
192
+ return target
193
+ ? {
194
+ type: "bend",
195
+ bend: [
196
+ { at: 0, cents: 0 },
197
+ { at: 1, cents },
198
+ ],
199
+ target,
200
+ }
201
+ : undefined;
202
+ }
203
+ const points: BendPoint[] = [];
204
+ let index = 0;
205
+ for (; index < words.length; index += 1) {
206
+ const pair = words[index]!.split(":");
207
+ if (pair.length !== 2 || !NUMBER.test(pair[0]!) || !NUMBER.test(pair[1]!))
208
+ break;
209
+ points.push({ at: Number(pair[0]), cents: Number(pair[1]) });
210
+ }
211
+ if (points.length === 0) return undefined;
212
+ const target = parseNoteTarget(words.slice(index));
213
+ if (!target) return undefined;
214
+ try {
215
+ return { type: "bend", bend: normalizeBend(points)!, target };
216
+ } catch {
217
+ return undefined;
218
+ }
219
+ }
220
+
221
+ function parseVibrato(words: string[]): ExpressionCommand | undefined {
222
+ const [first] = words;
223
+ if (!first) return undefined;
224
+ if (OFF.test(first)) {
225
+ const target = parseNoteTarget(words.slice(1));
226
+ return target ? { type: "vibrato", vibrato: null, target } : undefined;
227
+ }
228
+ const numbers: number[] = [];
229
+ let index = 0;
230
+ for (; index < words.length && numbers.length < 3; index += 1) {
231
+ const word = words[index]!.replace(/(hz|c|ct|cents|s)$/i, "");
232
+ if (!NUMBER.test(word)) break;
233
+ numbers.push(Number(word));
234
+ }
235
+ if (numbers.length === 0) return undefined;
236
+ const target = parseNoteTarget(words.slice(index));
237
+ if (!target) return undefined;
238
+ try {
239
+ const vibrato = normalizeVibrato({
240
+ rate: numbers[0],
241
+ ...(numbers[1] !== undefined ? { depth: numbers[1] } : {}),
242
+ ...(numbers[2] !== undefined ? { delay: numbers[2] } : {}),
243
+ });
244
+ return vibrato ? { type: "vibrato", vibrato, target } : undefined;
245
+ } catch {
246
+ return undefined;
247
+ }
248
+ }
249
+
250
+ function parseGlide(words: string[]): ExpressionCommand | undefined {
251
+ const [first, ...rest] = words;
252
+ if (!first) return undefined;
253
+ const mode = GLIDE_MODES.find((candidate) => candidate === first);
254
+ if (mode && rest.length === 0) return { type: "track-glide", mode };
255
+ // A bare number is milliseconds here (`glide 60`); `0.06s` is seconds. A
256
+ // bare fraction of a millisecond is a unit slip, not a glide.
257
+ if (NUMBER.test(first) && Number(first) > 0 && Number(first) < 1)
258
+ return {
259
+ type: "invalid",
260
+ message: `glide · a bare number is ms · write glide ${Math.round(Number(first) * 1000)}ms or glide ${first}s`,
261
+ };
262
+ const time = OFF.test(first)
263
+ ? null
264
+ : NUMBER.test(first)
265
+ ? Number(first) / 1000
266
+ : parseSeconds(first);
267
+ if (time === undefined || (time !== null && time < 0)) return undefined;
268
+ if (time !== null && time > EXPRESSION_LIMITS.maxGlideSeconds)
269
+ return undefined;
270
+ // `glide 0` on the track turns glide off; on a target it means never.
271
+ if (rest.length === 0)
272
+ return { type: "track-glide", time: time === 0 ? null : time };
273
+ const restMode = GLIDE_MODES.find((candidate) => candidate === rest[0]);
274
+ if (restMode && rest.length === 1)
275
+ return time === null
276
+ ? undefined
277
+ : { type: "track-glide", time, mode: restMode };
278
+ // A misspelt mode (`legatoo`) is a typo, not a note id.
279
+ if (rest.length === 1 && GLIDE_MODES.some((mode) => nearWord(rest[0]!, mode)))
280
+ return undefined;
281
+ const target = parseNoteTarget(rest);
282
+ return target ? { type: "note-glide", glide: time, target } : undefined;
283
+ }
284
+
285
+ /** Within two edits of `word` (a likely typo of it). */
286
+ function nearWord(text: string, word: string): boolean {
287
+ if (Math.abs(text.length - word.length) > 2) return false;
288
+ let previous = Array.from({ length: word.length + 1 }, (_, index) => index);
289
+ for (let i = 1; i <= text.length; i += 1) {
290
+ const current = [i];
291
+ for (let j = 1; j <= word.length; j += 1)
292
+ current[j] = Math.min(
293
+ previous[j]! + 1,
294
+ current[j - 1]! + 1,
295
+ previous[j - 1]! + (text[i - 1] === word[j - 1] ? 0 : 1),
296
+ );
297
+ previous = current;
298
+ }
299
+ return previous[word.length]! <= 2;
300
+ }
301
+
302
+ function parsePedal(words: string[]): ExpressionCommand | undefined {
303
+ const [first, ...rest] = words;
304
+ if (!first) return { type: "pedal-list" };
305
+ if (OFF.test(first) && rest.length === 0) return { type: "pedal-off" };
306
+ if (first === "bars" || first === "bar") {
307
+ if (rest.length === 0) return { type: "pedal-bars" };
308
+ const range =
309
+ rest.length === 1 ? rest[0]!.match(/^(\d+)(?:-(\d+))?$/) : null;
310
+ if (!range) return undefined;
311
+ const from = Number(range[1]);
312
+ const to = Number(range[2] ?? range[1]);
313
+ return from >= 1 && to >= from
314
+ ? { type: "pedal-bars", from, to }
315
+ : undefined;
316
+ }
317
+ if (first === "down" || first === "half" || first === "up") {
318
+ const beat = rest.length === 1 ? rest[0]!.replace(/^at$/, "") : "";
319
+ const value = rest.length === 2 && rest[0] === "at" ? rest[1]! : beat;
320
+ return NUMBER.test(value) && Number(value) >= 0
321
+ ? { type: "pedal-event", state: first, beat: Number(value) }
322
+ : undefined;
323
+ }
324
+ const spans: { from: number; to: number }[] = [];
325
+ for (const word of words) {
326
+ const span = word.match(/^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)$/);
327
+ if (!span) return undefined;
328
+ const from = Number(span[1]);
329
+ const to = Number(span[2]);
330
+ if (!(to > from)) return undefined;
331
+ spans.push({ from, to });
332
+ }
333
+ return { type: "pedal-spans", spans };
334
+ }
335
+
336
+ function parseHumanize(words: string[]): ExpressionCommand | undefined {
337
+ const [first, ...rest] = words;
338
+ if (!first) return { type: "show" };
339
+ if (OFF.test(first) && rest.length === 0) return { type: "humanize-off" };
340
+ // `humanize off|exact <target>`: clear the notes' own humanize, or keep
341
+ // them exact (`{}`) whatever the track does.
342
+ if ((OFF.test(first) || first === "exact") && rest.length > 0) {
343
+ const target = parseNoteTarget(rest);
344
+ return target
345
+ ? {
346
+ type: "note-humanize",
347
+ humanize: OFF.test(first) ? null : {},
348
+ target,
349
+ }
350
+ : undefined;
351
+ }
352
+ if (first === "on" && rest.length === 0)
353
+ return { type: "humanize", ...DEFAULT_HUMANIZE, length: 0 };
354
+ if (first === "reseed" && rest.length === 0) return { type: "humanize-seed" };
355
+ if (first === "seed")
356
+ return rest.length === 1 && /^\d+$/.test(rest[0]!)
357
+ ? { type: "humanize-seed", seed: Number(rest[0]) }
358
+ : undefined;
359
+ const amounts: number[] = [];
360
+ let seed: number | undefined;
361
+ let target: NoteTarget | undefined;
362
+ for (let index = 0; index < words.length; index += 1) {
363
+ const word = words[index]!;
364
+ if (word === "seed") {
365
+ const value = words[index + 1];
366
+ if (!value || !/^\d+$/.test(value) || index + 2 !== words.length)
367
+ return undefined;
368
+ seed = Number(value);
369
+ break;
370
+ }
371
+ const amount = word.replace(/(ms|%)$/i, "");
372
+ if (NUMBER.test(amount) && amounts.length < 3) {
373
+ if (Number(amount) < 0) return undefined;
374
+ amounts.push(Number(amount));
375
+ continue;
376
+ }
377
+ // Anything after the amounts is a note target (`humanize 10 8 bars 2-3`).
378
+ if (amounts.length === 0) return undefined;
379
+ target = parseNoteTarget(words.slice(index));
380
+ if (!target) return undefined;
381
+ break;
382
+ }
383
+ if (amounts.length === 0) return undefined;
384
+ const [timing = 0, velocity = 0, length = 0] = amounts;
385
+ if (
386
+ timing > EXPRESSION_LIMITS.maxHumanizeTimingMs ||
387
+ velocity > EXPRESSION_LIMITS.maxHumanizePercent ||
388
+ length > EXPRESSION_LIMITS.maxHumanizePercent ||
389
+ (seed !== undefined && seed > EXPRESSION_LIMITS.maxSeed)
390
+ )
391
+ return undefined;
392
+ if (target)
393
+ return {
394
+ type: "note-humanize",
395
+ humanize: {
396
+ ...(timing > 0 ? { timing } : {}),
397
+ ...(velocity > 0 ? { velocity } : {}),
398
+ ...(length > 0 ? { length } : {}),
399
+ },
400
+ target,
401
+ };
402
+ return {
403
+ type: "humanize",
404
+ timing,
405
+ velocity,
406
+ length,
407
+ ...(seed !== undefined ? { seed } : {}),
408
+ };
409
+ }
410
+
411
+ export function parseExpressionCommand(
412
+ prompt: string,
413
+ ): ExpressionCommand | undefined {
414
+ const text = prompt.trim().replace(/\s+/g, " ");
415
+ if (text.length > 1_024) return undefined;
416
+ const [verb, ...words] = text.replace(/^\//, "").split(" ");
417
+ switch (verb?.toLowerCase()) {
418
+ case "art":
419
+ case "articulation": {
420
+ const [first, ...rest] = words;
421
+ if (!first) return undefined;
422
+ const articulation = OFF.test(first) ? null : articulationWord(first);
423
+ if (articulation === undefined) return undefined;
424
+ const target = parseNoteTarget(rest);
425
+ return target
426
+ ? { type: "articulation", articulation, target }
427
+ : undefined;
428
+ }
429
+ case "bend":
430
+ return parseBend(words);
431
+ case "vibrato":
432
+ return parseVibrato(words);
433
+ case "glide":
434
+ case "portamento":
435
+ return parseGlide(words.map((word) => word.toLowerCase()));
436
+ case "pedal":
437
+ case "sustain":
438
+ return parsePedal(words.map((word) => word.toLowerCase()));
439
+ case "velcurve":
440
+ case "vel-curve": {
441
+ const [first, value, extra] = words.map((word) => word.toLowerCase());
442
+ const curve = VELOCITY_CURVES.find((candidate) => candidate === first);
443
+ if (!curve || extra !== undefined) return undefined;
444
+ if (value === undefined) return { type: "velcurve", curve };
445
+ if (curve !== "fixed" || !NUMBER.test(value)) return undefined;
446
+ // 0..1, like note velocity everywhere else in dawg.
447
+ const fixed = Number(value);
448
+ return fixed >= 0 && fixed <= 1
449
+ ? { type: "velcurve", curve, fixed }
450
+ : undefined;
451
+ }
452
+ case "humanize":
453
+ return parseHumanize(words.map((word) => word.toLowerCase()));
454
+ case "expression":
455
+ case "performance":
456
+ return words.length === 0 ? { type: "show" } : undefined;
457
+ default:
458
+ return undefined;
459
+ }
460
+ }
461
+
462
+ export type ExpressionResult = Readonly<{
463
+ ok: boolean;
464
+ message: string;
465
+ next?: TrackScore;
466
+ kind?: string;
467
+ payload?: Record<string, unknown>;
468
+ }>;
469
+
470
+ /** The notes `target` names: ids anywhere, otherwise notes on `trackId`. */
471
+ export function targetNotes(
472
+ score: TrackScore,
473
+ trackId: string,
474
+ target: NoteTarget,
475
+ ): Note[] {
476
+ if (target.type === "ids") {
477
+ const ids = new Set(target.ids);
478
+ return score.notes.filter((note) => ids.has(note.id));
479
+ }
480
+ const notes = score.notes.filter((note) => note.trackId === trackId);
481
+ if (target.type === "all") return notes;
482
+ if (target.type === "last") return notes.slice(-1);
483
+ // Bars follow the meter map: bar n runs from the start of bar n to n + 1.
484
+ const from = barStartTick(score, target.from - 1);
485
+ const to = barStartTick(score, target.to);
486
+ return notes.filter((note) => note.startTick >= from && note.startTick < to);
487
+ }
488
+
489
+ export function describeTarget(target: NoteTarget): string {
490
+ if (target.type === "all") return "all notes";
491
+ if (target.type === "last") return "last note";
492
+ if (target.type === "ids")
493
+ return target.ids.length === 1
494
+ ? target.ids[0]!
495
+ : `${target.ids.length} ids`;
496
+ return target.from === target.to
497
+ ? `bar ${target.from}`
498
+ : `bars ${target.from}-${target.to}`;
499
+ }
500
+
501
+ /**
502
+ * Apply `patch` to every note in `noteIds` as one new score (one revision).
503
+ * Throws ScoreValidationError when a value is out of range.
504
+ */
505
+ export function patchNotes(
506
+ score: TrackScore,
507
+ noteIds: ReadonlySet<string>,
508
+ patch: NoteExpressionPatch,
509
+ ): TrackScore {
510
+ if (noteIds.size === 0) return score;
511
+ return new TrackScore({
512
+ ...score.toJSON(),
513
+ notes: score.notes.map((note) => {
514
+ if (!noteIds.has(note.id)) return note;
515
+ const next: Record<string, unknown> = { ...note };
516
+ for (const [key, value] of Object.entries(patch)) {
517
+ if (value === null) delete next[key];
518
+ else if (value !== undefined) next[key] = value;
519
+ }
520
+ return next as Note;
521
+ }),
522
+ });
523
+ }
524
+
525
+ function cents(value: number): string {
526
+ return `${value > 0 ? "+" : ""}${Math.round(value)}c`;
527
+ }
528
+
529
+ function ms(seconds: number): string {
530
+ return `${Math.round(seconds * 1000)} ms`;
531
+ }
532
+
533
+ export function describeBend(bend: readonly BendPoint[]): string {
534
+ const shape = Object.entries(BEND_SHAPES).find(
535
+ ([, points]) =>
536
+ points.length === bend.length &&
537
+ points.every(
538
+ (point, index) =>
539
+ point.at === bend[index]!.at && point.cents === bend[index]!.cents,
540
+ ),
541
+ );
542
+ if (shape) return shape[0];
543
+ return bend.map((point) => `${point.at}:${cents(point.cents)}`).join(" ");
544
+ }
545
+
546
+ export function describeVibrato(vibrato: NoteVibrato): string {
547
+ return `${vibrato.rate} Hz ±${vibrato.depth}c${vibrato.delay ? ` after ${ms(vibrato.delay)}` : ""}`;
548
+ }
549
+
550
+ export function describeHumanize(humanize: Humanize | undefined): string {
551
+ if (!humanize) return "off";
552
+ return `±${humanize.timing ?? 0} ms · vel ±${humanize.velocity ?? 0}% · len ±${humanize.length ?? 0}% · seed ${humanize.seed}`;
553
+ }
554
+
555
+ function describeNoteHumanize(humanize: NoteHumanize): string {
556
+ return `±${humanize.timing ?? 0} ms · vel ±${humanize.velocity ?? 0}% · len ±${humanize.length ?? 0}%`;
557
+ }
558
+
559
+ /** One line for the track's performance settings and note expression. */
560
+ export function describePerformance(score: TrackScore, track: Track): string {
561
+ const notes = score.notes.filter((note) => note.trackId === track.id);
562
+ const count = (key: keyof Note) =>
563
+ notes.filter((note) => note[key] !== undefined).length;
564
+ const parts = [
565
+ `glide ${track.glide ? `${ms(track.glide.time)} ${track.glide.mode}` : "off"}`,
566
+ `pedal ${track.pedal ? `${track.pedal.length} event${track.pedal.length === 1 ? "" : "s"}` : "off"}`,
567
+ `velcurve ${track.velocityCurve ? `${track.velocityCurve.curve}${track.velocityCurve.fixed !== undefined ? ` ${track.velocityCurve.fixed}` : ""}` : "linear"}`,
568
+ `humanize ${describeHumanize(track.humanize)}`,
569
+ ];
570
+ const marked = [
571
+ ["art", count("articulation")],
572
+ ["bend", count("bend")],
573
+ ["vibrato", count("vibrato")],
574
+ ["glide", count("glide")],
575
+ ].filter(([, value]) => (value as number) > 0);
576
+ return `expression · ${track.id} · ${parts.join(" · ")}${
577
+ marked.length > 0
578
+ ? ` · notes ${marked.map(([name, value]) => `${name} ${value}`).join(", ")}`
579
+ : ""
580
+ }`;
581
+ }
582
+
583
+ function noteResult(
584
+ score: TrackScore,
585
+ trackId: string,
586
+ target: NoteTarget,
587
+ patch: NoteExpressionPatch,
588
+ label: string,
589
+ value: string,
590
+ ): ExpressionResult {
591
+ const notes = targetNotes(score, trackId, target);
592
+ if (target.type === "ids") {
593
+ const known = new Set(notes.map((note) => note.id));
594
+ const unknown = target.ids.find((id) => !known.has(id));
595
+ if (unknown !== undefined)
596
+ return {
597
+ ok: false,
598
+ message: `${label} · unknown note id ${unknown} · targets: all, last, bar 3, bars 2-4, ids`,
599
+ };
600
+ }
601
+ if (notes.length === 0)
602
+ return {
603
+ ok: false,
604
+ message: `${label} · no notes in ${describeTarget(target)} on ${trackId}`,
605
+ };
606
+ const noteIds = new Set(notes.map((note) => note.id));
607
+ let next: TrackScore;
608
+ try {
609
+ next = patchNotes(score, noteIds, patch);
610
+ } catch (error) {
611
+ if (
612
+ error instanceof ScoreValidationError ||
613
+ error instanceof ExpressionValidationError
614
+ )
615
+ return { ok: false, message: `${label} · ${error.message}` };
616
+ throw error;
617
+ }
618
+ return {
619
+ ok: true,
620
+ message: `${label} · ${value} · ${notes.length} note${notes.length === 1 ? "" : "s"} (${describeTarget(target)})`,
621
+ next,
622
+ kind: "score.expression",
623
+ payload: { trackId, noteIds: [...noteIds], patch },
624
+ };
625
+ }
626
+
627
+ function trackResult(
628
+ score: TrackScore,
629
+ track: Track,
630
+ patch: Readonly<Record<string, unknown>>,
631
+ message: string,
632
+ ): ExpressionResult {
633
+ let next: TrackScore;
634
+ try {
635
+ next = updateTrack(score, track.id, patch);
636
+ } catch (error) {
637
+ if (
638
+ error instanceof ScoreValidationError ||
639
+ error instanceof ExpressionValidationError
640
+ )
641
+ return {
642
+ ok: false,
643
+ message: message.split(" · ")[0] + ` · ${error.message}`,
644
+ };
645
+ throw error;
646
+ }
647
+ return {
648
+ ok: true,
649
+ message,
650
+ next,
651
+ kind: "score.performance",
652
+ payload: { trackId: track.id, patch },
653
+ };
654
+ }
655
+
656
+ /** Pedal events that re-pedal at every bar line of bars `from`..`to`. */
657
+ export function barPedal(
658
+ score: TrackScore,
659
+ from = 1,
660
+ to = score.bars,
661
+ ): PedalEvent[] {
662
+ // Legato (syncopated) pedalling: lift at the bar line, catch the new
663
+ // harmony a 32nd note later so the previous chord does not blur into it.
664
+ const catchTicks = Math.max(1, Math.round(score.ticksPerBeat / 8));
665
+ const events: PedalEvent[] = [];
666
+ const last = Math.min(to, score.bars);
667
+ for (let bar = from; bar <= last; bar += 1) {
668
+ const start = barStartTick(score, bar - 1);
669
+ if (bar > from) events.push({ tick: start, state: "up" });
670
+ events.push({ tick: start + (bar > from ? catchTicks : 0), state: "down" });
671
+ }
672
+ events.push({
673
+ tick: Math.min(barStartTick(score, last), loopTicksOf(score)),
674
+ state: "up",
675
+ });
676
+ return events;
677
+ }
678
+
679
+ function mergePedal(
680
+ current: readonly PedalEvent[] | undefined,
681
+ added: readonly PedalEvent[],
682
+ maxTick: number,
683
+ ): readonly PedalEvent[] | undefined {
684
+ const byTick = new Map((current ?? []).map((event) => [event.tick, event]));
685
+ for (const event of added) byTick.set(event.tick, event);
686
+ return normalizePedal([...byTick.values()], maxTick);
687
+ }
688
+
689
+ /** Apply `command` to the focused track `trackId`. */
690
+ export function applyExpressionCommand(
691
+ score: TrackScore,
692
+ trackId: string,
693
+ command: ExpressionCommand,
694
+ ): ExpressionResult {
695
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
696
+ if (!track) return { ok: false, message: `no track · ${trackId}` };
697
+ const tpb = score.ticksPerBeat;
698
+ const maxTick = loopTicksOf(score);
699
+ switch (command.type) {
700
+ case "show":
701
+ return { ok: true, message: describePerformance(score, track) };
702
+ case "invalid":
703
+ return { ok: false, message: command.message };
704
+ case "articulation":
705
+ return noteResult(
706
+ score,
707
+ trackId,
708
+ command.target,
709
+ { articulation: command.articulation },
710
+ "art",
711
+ command.articulation
712
+ ? `${command.articulation} (${ARTICULATION_EFFECTS[command.articulation].doc})`
713
+ : "off",
714
+ );
715
+ case "bend":
716
+ return noteResult(
717
+ score,
718
+ trackId,
719
+ command.target,
720
+ { bend: command.bend },
721
+ "bend",
722
+ command.bend ? describeBend(command.bend) : "off",
723
+ );
724
+ case "vibrato":
725
+ return noteResult(
726
+ score,
727
+ trackId,
728
+ command.target,
729
+ { vibrato: command.vibrato },
730
+ "vibrato",
731
+ command.vibrato ? describeVibrato(command.vibrato) : "off",
732
+ );
733
+ case "note-glide":
734
+ return noteResult(
735
+ score,
736
+ trackId,
737
+ command.target,
738
+ { glide: command.glide },
739
+ "glide",
740
+ command.glide === null
741
+ ? "off"
742
+ : command.glide === 0
743
+ ? "never"
744
+ : ms(command.glide),
745
+ );
746
+ case "track-glide": {
747
+ if (command.time === null)
748
+ return track.glide
749
+ ? trackResult(
750
+ score,
751
+ track,
752
+ { glide: null },
753
+ `glide · off · ${trackId}`,
754
+ )
755
+ : { ok: true, message: `glide · already off · ${trackId}` };
756
+ const glide = normalizeTrackGlide({
757
+ time: command.time ?? track.glide?.time ?? DEFAULT_GLIDE_SECONDS,
758
+ mode: command.mode ?? track.glide?.mode ?? "legato",
759
+ })!;
760
+ const how =
761
+ glide.mode === "legato"
762
+ ? "slides only between overlapping notes"
763
+ : glide.mode === "mono"
764
+ ? "one voice, every note slides"
765
+ : "every voice slides from the previous note";
766
+ return trackResult(
767
+ score,
768
+ track,
769
+ { glide },
770
+ `glide · ${ms(glide.time)} ${glide.mode} (${how}) · ${trackId}`,
771
+ );
772
+ }
773
+ case "pedal-list": {
774
+ const events = track.pedal ?? [];
775
+ if (events.length === 0)
776
+ return {
777
+ ok: true,
778
+ message: `pedal · none on ${trackId} · pedal bars to add`,
779
+ };
780
+ const shown = events
781
+ .slice(0, 12)
782
+ .map(
783
+ (event) =>
784
+ `${Math.round((event.tick / score.ticksPerBeat) * 1000) / 1000} ${event.state}`,
785
+ );
786
+ if (events.length > 12) shown.push(`+${events.length - 12} more`);
787
+ return { ok: true, message: `pedal · ${shown.join(" · ")} · ${trackId}` };
788
+ }
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
+ case "pedal-spans":
794
+ case "pedal-bars":
795
+ case "pedal-event": {
796
+ let added: PedalEvent[];
797
+ if (command.type === "pedal-bars") {
798
+ const from = command.from ?? 1;
799
+ if (from > score.bars)
800
+ return {
801
+ ok: false,
802
+ message: `pedal · the loop has ${score.bars} bar${score.bars === 1 ? "" : "s"}`,
803
+ };
804
+ added = barPedal(score, from, command.to ?? score.bars);
805
+ } else if (command.type === "pedal-event") {
806
+ added = [
807
+ { tick: Math.round(command.beat * tpb), state: command.state },
808
+ ];
809
+ } else {
810
+ added = command.spans.flatMap((span) => [
811
+ { tick: Math.round(span.from * tpb), state: "down" as const },
812
+ { tick: Math.round(span.to * tpb), state: "up" as const },
813
+ ]);
814
+ }
815
+ if (added.some((event) => event.tick > maxTick))
816
+ return {
817
+ ok: false,
818
+ message: `pedal · beats run 0..${maxTick / tpb} in this loop`,
819
+ };
820
+ let pedal: readonly PedalEvent[] | undefined;
821
+ try {
822
+ // `pedal bars` over the whole loop replaces the lane (as the agent's
823
+ // `pedal: "bars"` does); spans, events and bar ranges add to it.
824
+ pedal =
825
+ command.type === "pedal-bars" &&
826
+ command.from === undefined &&
827
+ command.to === undefined
828
+ ? normalizePedal(added, maxTick)
829
+ : mergePedal(track.pedal, added, maxTick);
830
+ } catch (error) {
831
+ if (error instanceof ExpressionValidationError)
832
+ return { ok: false, message: `pedal · ${error.message}` };
833
+ throw error;
834
+ }
835
+ const downs = (pedal ?? []).filter((event) => event.state !== "up");
836
+ return trackResult(
837
+ score,
838
+ track,
839
+ { pedal: pedal ?? null },
840
+ `pedal · ${pedal?.length ?? 0} event${pedal?.length === 1 ? "" : "s"} (${downs.length} down) · ${trackId}`,
841
+ );
842
+ }
843
+ case "velcurve": {
844
+ const velocityCurve = normalizeVelocityCurve({
845
+ curve: command.curve,
846
+ ...(command.fixed !== undefined ? { fixed: command.fixed } : {}),
847
+ });
848
+ return trackResult(
849
+ score,
850
+ track,
851
+ { velocityCurve: velocityCurve ?? null },
852
+ `velcurve · ${command.curve}${velocityCurve?.fixed !== undefined ? ` ${Math.round(velocityCurve.fixed * 100) / 100} (MIDI ${Math.round(velocityCurve.fixed * 127)})` : ""} · ${trackId}`,
853
+ );
854
+ }
855
+ case "note-humanize":
856
+ return noteResult(
857
+ score,
858
+ trackId,
859
+ command.target,
860
+ { humanize: command.humanize },
861
+ "humanize",
862
+ command.humanize === null
863
+ ? "off (follows the track)"
864
+ : Object.keys(command.humanize).length === 0
865
+ ? "exact"
866
+ : describeNoteHumanize(command.humanize),
867
+ );
868
+ case "humanize-off":
869
+ return track.humanize
870
+ ? trackResult(
871
+ score,
872
+ track,
873
+ { humanize: null },
874
+ `humanize · off · ${trackId}`,
875
+ )
876
+ : { ok: true, message: `humanize · already off · ${trackId}` };
877
+ case "humanize-seed": {
878
+ if (!track.humanize)
879
+ return {
880
+ ok: false,
881
+ message: "humanize · off · turn it on first · humanize 10 8",
882
+ };
883
+ const seed =
884
+ command.seed ??
885
+ (track.humanize.seed + 1) % (EXPRESSION_LIMITS.maxSeed + 1);
886
+ const humanize = normalizeHumanize({ ...track.humanize, seed })!;
887
+ return trackResult(
888
+ score,
889
+ track,
890
+ { humanize },
891
+ `humanize · ${describeHumanize(humanize)} · ${trackId}`,
892
+ );
893
+ }
894
+ case "humanize": {
895
+ const humanize = normalizeHumanize({
896
+ timing: command.timing,
897
+ velocity: command.velocity,
898
+ length: command.length,
899
+ seed: command.seed ?? track.humanize?.seed ?? 1,
900
+ });
901
+ if (!humanize)
902
+ return track.humanize
903
+ ? trackResult(
904
+ score,
905
+ track,
906
+ { humanize: null },
907
+ `humanize · off · ${trackId}`,
908
+ )
909
+ : { ok: true, message: `humanize · already off · ${trackId}` };
910
+ return trackResult(
911
+ score,
912
+ track,
913
+ { humanize },
914
+ `humanize · ${describeHumanize(humanize)} · ${trackId}`,
915
+ );
916
+ }
917
+ }
918
+ }
919
+
920
+ /** Usage lines, also the /help and typo hints. */
921
+ export const EXPRESSION_USAGE = Object.freeze({
922
+ art: `art ${ARTICULATIONS.join("|")}|off [all|last|bar <n>|bars <a>-<b>|<ids>] · art staccato bars 1-2`,
923
+ bend: "bend <cents>|scoop|fall|doit|<at:cents>…|off [target] · bend +200 · bend 0:0 0.5:200",
924
+ vibrato:
925
+ "vibrato <rate Hz> <depth cents> [<delay s>]|off [target] · vibrato 5.5 30 0.2",
926
+ glide:
927
+ "glide <ms>|<s>s|off [legato|mono|poly] (track) · glide <ms>|off <target> (notes) · glide 60 mono · SDK glide: 0.06 (s)",
928
+ pedal:
929
+ "pedal <beat>-<beat>…|bars [<bar>-<bar>]|down|half|up <beat>|off · pedal 0-3.5 4-7.5 · pedal bars",
930
+ velcurve:
931
+ "velcurve linear|soft|hard|fixed [<v 0..1>] · velcurve soft · velcurve fixed 0.8",
932
+ humanize:
933
+ "humanize <ms> [<vel%> [<len%>]] [seed <n>]|on|off|reseed (track) · humanize <ms> [<vel%> [<len%>]]|exact|off <target> (notes) · humanize 10 8 5 · humanize 20 bars 2-3",
934
+ });