@hraness/dawg 0.5.0 → 0.6.1

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 (124) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/DAWG.md +592 -49
  3. package/README.md +4 -4
  4. package/core/chords.ts +492 -4
  5. package/core/diff.ts +9 -0
  6. package/core/expression.ts +143 -6
  7. package/core/fx.ts +689 -2
  8. package/core/granular.ts +619 -0
  9. package/core/instruments.ts +442 -0
  10. package/core/keys.ts +955 -0
  11. package/core/resonators.ts +862 -0
  12. package/core/score.ts +529 -8
  13. package/core/sdk/eval-child.ts +2 -0
  14. package/core/sdk/print.ts +247 -16
  15. package/core/sdk/sync-instruments.ts +58 -0
  16. package/core/sdk/v1.ts +2417 -40
  17. package/core/sections.ts +44 -10
  18. package/core/strings.ts +1080 -0
  19. package/core/winds.ts +652 -0
  20. package/guides/automation.md +1 -0
  21. package/guides/chords.md +3 -1
  22. package/guides/effects.md +5 -2
  23. package/guides/media.md +1 -1
  24. package/guides/performance.md +3 -0
  25. package/guides/resample.md +26 -0
  26. package/guides/sounds.md +7 -2
  27. package/guides/tempo.md +1 -0
  28. package/package.json +1 -1
  29. package/src/agent/agent.ts +13 -0
  30. package/src/agent/chord-tools.ts +153 -1
  31. package/src/agent/expression-tools.ts +91 -0
  32. package/src/agent/granular-tools.ts +138 -0
  33. package/src/agent/models.ts +4 -4
  34. package/src/agent/ops.ts +42 -2
  35. package/src/agent/preview-tool.ts +7 -1
  36. package/src/agent/resample-tool.ts +131 -0
  37. package/src/agent/tools.ts +726 -17
  38. package/src/agent/xcb-agent.ts +13 -0
  39. package/src/audio/arrange.ts +42 -3
  40. package/src/audio/dsp/bank.ts +233 -0
  41. package/src/audio/dsp/envelope.ts +161 -0
  42. package/src/audio/dsp/fft.ts +6 -0
  43. package/src/audio/dsp/filters.ts +57 -0
  44. package/src/audio/dsp/interp.ts +112 -0
  45. package/src/audio/dsp/modal.ts +684 -0
  46. package/src/audio/dsp/onset.ts +197 -0
  47. package/src/audio/dsp/oversample.ts +202 -0
  48. package/src/audio/dsp/rng.ts +37 -0
  49. package/src/audio/dsp/shape.ts +81 -0
  50. package/src/audio/dsp/shift.ts +256 -0
  51. package/src/audio/dsp/stft.ts +73 -0
  52. package/src/audio/dsp/window.ts +77 -0
  53. package/src/audio/effects/chain.ts +23 -3
  54. package/src/audio/effects/common.ts +14 -0
  55. package/src/audio/effects/convolution.ts +106 -1
  56. package/src/audio/effects/gaze.ts +354 -0
  57. package/src/audio/effects/rig/cab.ts +99 -0
  58. package/src/audio/effects/rig/filters.ts +152 -0
  59. package/src/audio/effects/rig/gate.ts +39 -0
  60. package/src/audio/effects/rig/head.ts +487 -0
  61. package/src/audio/effects/rig/index.ts +170 -0
  62. package/src/audio/effects/rig/section.ts +78 -0
  63. package/src/audio/effects/rig/stomp.ts +238 -0
  64. package/src/audio/engine.ts +56 -6
  65. package/src/audio/fit.ts +447 -0
  66. package/src/audio/granular.ts +995 -0
  67. package/src/audio/instrument-check.ts +137 -0
  68. package/src/audio/instruments.ts +140 -0
  69. package/src/audio/keys/dsp.ts +323 -0
  70. package/src/audio/keys/electric.ts +420 -0
  71. package/src/audio/keys/engine.ts +485 -0
  72. package/src/audio/keys/organ.ts +1335 -0
  73. package/src/audio/keys/piano.ts +476 -0
  74. package/src/audio/keys/sympathetic.ts +127 -0
  75. package/src/audio/live-worker.ts +56 -0
  76. package/src/audio/live.ts +361 -15
  77. package/src/audio/loudness.ts +65 -20
  78. package/src/audio/preview.ts +17 -1
  79. package/src/audio/resample.ts +285 -0
  80. package/src/audio/resonators.ts +295 -0
  81. package/src/audio/sampler.ts +328 -35
  82. package/src/audio/samples.ts +56 -8
  83. package/src/audio/strings/body.ts +263 -0
  84. package/src/audio/strings/bow.ts +656 -0
  85. package/src/audio/strings/engine.ts +615 -0
  86. package/src/audio/strings/loop.ts +119 -0
  87. package/src/audio/strings/measure.test-helpers.ts +200 -0
  88. package/src/audio/strings/pluck.ts +354 -0
  89. package/src/audio/warp.ts +61 -0
  90. package/src/audio/wav.ts +191 -19
  91. package/src/audio/winds/engine.ts +302 -0
  92. package/src/audio/winds/filters.ts +153 -0
  93. package/src/audio/winds/pitch.ts +104 -0
  94. package/src/audio/winds/trim.ts +80 -0
  95. package/src/audio/winds/trims.ts +917 -0
  96. package/src/audio/winds/voice.ts +479 -0
  97. package/src/commands/expression.ts +111 -23
  98. package/src/commands/fit.ts +135 -0
  99. package/src/commands/fx.ts +35 -1
  100. package/src/commands/granular.ts +432 -0
  101. package/src/commands/help.ts +159 -5
  102. package/src/commands/keys.ts +629 -0
  103. package/src/commands/modal.ts +310 -0
  104. package/src/commands/resample.ts +281 -0
  105. package/src/commands/rig.ts +264 -0
  106. package/src/commands/sample.ts +52 -2
  107. package/src/commands/shift.ts +119 -0
  108. package/src/commands/string.ts +201 -0
  109. package/src/commands/strum.ts +473 -0
  110. package/src/commands/time.ts +4 -1
  111. package/src/commands/wind.ts +244 -0
  112. package/src/main.ts +339 -15
  113. package/src/project/check.ts +17 -0
  114. package/src/render.ts +49 -4
  115. package/src/session/presence.ts +16 -3
  116. package/src/tui/audition.ts +2 -2
  117. package/src/tui/granular-menu.ts +278 -0
  118. package/src/tui/menu.ts +934 -9
  119. package/src/tui/modal-menu.ts +145 -0
  120. package/src/tui/performance-menu.ts +42 -0
  121. package/src/tui/play-chords.ts +104 -3
  122. package/src/tui/play-mode.ts +1 -0
  123. package/src/tui/play-session.ts +70 -2
  124. package/src/tui/wind-menu.ts +144 -0
@@ -0,0 +1,264 @@
1
+ /**
2
+ * The guitar rig prompt commands (0.6):
3
+ *
4
+ * rig show the focused track's rig
5
+ * rig <preset> load a whole rig (RIG_PRESETS): clean crunch
6
+ * punk ragged lead metal fuzz octave funk wah
7
+ * bachata spring bassdrive reese jangle alt
8
+ * rig reset | rig off remove the stomp, head and cab
9
+ * stomp|head|cab <type> pick a pedal, amp or cabinet (`head crunch`)
10
+ * stomp|head|cab <param> <v>… set parameters (`head gain 7 gate -55`)
11
+ * stomp|head|cab on|off|reset as `fx <stage> …`
12
+ *
13
+ * Stage commands are `fx stomp|head|cab …` without the `fx`. `fx amp` (and
14
+ * `amp`) answer with a hint: `amp` stays Strudel's linear gain, the guitar
15
+ * amplifier is `head`. One command is one `updateTrack` revision.
16
+ */
17
+ import {
18
+ FX_PRESETS,
19
+ FxValidationError,
20
+ RIG_PRESETS,
21
+ RIG_STAGES,
22
+ applyRigPreset,
23
+ effectSpec,
24
+ isRigReverb,
25
+ rigPresetOf,
26
+ rigReverb,
27
+ type FxValues,
28
+ type RigStage,
29
+ type TrackFx,
30
+ SHOEGAZE_EFFECTS,
31
+ } from "../../core/fx.ts";
32
+ import {
33
+ instrumentForWord,
34
+ resolveInstrumentWord,
35
+ } from "../../core/instruments.ts";
36
+ import {
37
+ ScoreValidationError,
38
+ updateTrack,
39
+ type TrackPatch,
40
+ type TrackScore,
41
+ } from "../../core/score.ts";
42
+ import {
43
+ applyFxCommand,
44
+ parseFxCommand,
45
+ type FxCommand,
46
+ type FxResult,
47
+ } from "./fx.ts";
48
+
49
+ export type RigCommand =
50
+ | { type: "rig-show" }
51
+ | { type: "rig-preset"; preset: string }
52
+ | { type: "rig-reset" }
53
+ | { type: "rig-stage"; stage: RigStage; fx: FxCommand }
54
+ /** A typed word that is close to a rig command: answer with a hint. */
55
+ | { type: "rig-hint"; message: string };
56
+
57
+ export const RIG_PRESET_NAMES: readonly string[] = Object.freeze(
58
+ Object.keys(RIG_PRESETS),
59
+ );
60
+
61
+ const AMP_HINT =
62
+ "did you mean head (guitar amp)? · `head crunch`, `rig crunch` · amp stays Strudel linear gain (`fx gain`)";
63
+
64
+ function isStage(word: string): word is RigStage {
65
+ return (RIG_STAGES as readonly string[]).includes(word);
66
+ }
67
+
68
+ /** `unknown rig crunh · did you mean crunch? · rigs clean crunch …` */
69
+ function unknownRigMessage(name: string): string {
70
+ let best: { name: string; distance: number } | undefined;
71
+ for (const candidate of RIG_PRESET_NAMES) {
72
+ const distance = editDistance(name, candidate);
73
+ if (!best || distance < best.distance) best = { name: candidate, distance };
74
+ }
75
+ const near =
76
+ best && best.distance <= Math.max(2, Math.floor(name.length / 3))
77
+ ? ` · did you mean ${best.name}?`
78
+ : "";
79
+ return `unknown rig ${name}${near} · rigs ${RIG_PRESET_NAMES.join(" ")} reset`;
80
+ }
81
+
82
+ function editDistance(a: string, b: string): number {
83
+ let row = Array.from({ length: b.length + 1 }, (_, i) => i);
84
+ for (let i = 1; i <= a.length; i++) {
85
+ const next = [i];
86
+ for (let j = 1; j <= b.length; j++)
87
+ next[j] = Math.min(
88
+ row[j]! + 1,
89
+ next[j - 1]! + 1,
90
+ row[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
91
+ );
92
+ row = next;
93
+ }
94
+ return row[b.length]!;
95
+ }
96
+
97
+ export function parseRigCommand(prompt: string): RigCommand | undefined {
98
+ if (prompt.length > 1_024) return undefined;
99
+ const words = prompt.trim().replace(/^\//, "").toLowerCase().split(/\s+/);
100
+ const head = words[0];
101
+ if (head === "amp" || (head === "fx" && words[1] === "amp"))
102
+ return { type: "rig-hint", message: AMP_HINT };
103
+ if (head === "rig") {
104
+ if (words.length === 1) return { type: "rig-show" };
105
+ const name = words.slice(1).join(" ");
106
+ if (name === "reset" || name === "off" || name === "none")
107
+ return { type: "rig-reset" };
108
+ if (Object.prototype.hasOwnProperty.call(RIG_PRESETS, name))
109
+ return { type: "rig-preset", preset: name };
110
+ return { type: "rig-hint", message: unknownRigMessage(name) };
111
+ }
112
+ if (!head || !isStage(head)) return undefined;
113
+ const stage = head;
114
+ const rest = words.slice(1);
115
+ if (rest.length === 0)
116
+ return { type: "rig-stage", stage, fx: { type: "fx-on", effect: stage } };
117
+ // `stomp fuzz`, `head crunch`, `cab 4x12`: the stage's type.
118
+ const types = effectSpec(stage).params.type;
119
+ if (
120
+ rest.length === 1 &&
121
+ types?.kind === "enum" &&
122
+ (types.values as readonly string[]).includes(rest[0]!)
123
+ )
124
+ return {
125
+ type: "rig-stage",
126
+ stage,
127
+ fx: { type: "fx-set", effect: stage, values: { type: rest[0]! } },
128
+ };
129
+ // `stomp muff`: a stage preset.
130
+ if (
131
+ rest.length === 1 &&
132
+ Object.prototype.hasOwnProperty.call(FX_PRESETS[stage] ?? {}, rest[0]!)
133
+ )
134
+ return {
135
+ type: "rig-stage",
136
+ stage,
137
+ fx: { type: "fx-preset", effect: stage, preset: rest[0]! },
138
+ };
139
+ const fx = parseFxCommand(`fx ${stage} ${rest.join(" ")}`);
140
+ return fx ? { type: "rig-stage", stage, fx } : undefined;
141
+ }
142
+
143
+ function describeRig(values: Readonly<Record<string, FxValues | undefined>>) {
144
+ const stages = RIG_STAGES.filter((stage) => values[stage])
145
+ .map((stage) => {
146
+ const v = values[stage]!;
147
+ const type = String(v.type);
148
+ const gain = v.gain === undefined ? "" : ` ${String(v.gain)}`;
149
+ return `${stage} ${type}${gain}`;
150
+ })
151
+ .join(" → ");
152
+ // 0.6.1 shoegaze stages follow the cab in the chain.
153
+ const gaze = SHOEGAZE_EFFECTS.filter((effect) => values[effect]);
154
+ return gaze.length ? `${stages} + ${gaze.join(" ")}` : stages;
155
+ }
156
+
157
+ export function applyRigCommand(
158
+ score: TrackScore,
159
+ trackId: string,
160
+ command: RigCommand,
161
+ ): FxResult {
162
+ if (command.type === "rig-hint")
163
+ return { ok: false, message: command.message };
164
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
165
+ if (!track) return { ok: false, message: `no track · ${trackId}` };
166
+ if (command.type === "rig-stage")
167
+ return applyFxCommand(score, trackId, command.fx);
168
+ if (command.type === "rig-show") {
169
+ const fx = track.fx ?? {};
170
+ if (!RIG_STAGES.some((stage) => fx[stage]))
171
+ return {
172
+ ok: true,
173
+ message: `rig · none · rigs ${RIG_PRESET_NAMES.join(" ")}`,
174
+ };
175
+ const name = rigPresetOf(track.fx);
176
+ return {
177
+ ok: true,
178
+ message: `rig${name ? ` ${name}` : ""} · ${describeRig(fx)}`,
179
+ };
180
+ }
181
+ const name = command.type === "rig-reset" ? "reset" : command.preset;
182
+ if (name === "reset" && !RIG_STAGES.some((stage) => track.fx?.[stage]))
183
+ return { ok: true, message: "rig · already off" };
184
+ let next: TrackScore;
185
+ try {
186
+ next = updateTrack(score, trackId, rigPatch(track, name));
187
+ } catch (error) {
188
+ if (
189
+ error instanceof ScoreValidationError ||
190
+ error instanceof FxValidationError
191
+ )
192
+ return { ok: false, message: `rig · ${error.message}` };
193
+ throw error;
194
+ }
195
+ const stored = next.tracks.find((candidate) => candidate.id === trackId)?.fx;
196
+ return {
197
+ ok: true,
198
+ message:
199
+ name === "reset"
200
+ ? "rig · off"
201
+ : `rig ${name} · ${describeRig(stored ?? {})}`,
202
+ next,
203
+ kind: "score.effect",
204
+ payload: { trackId, rig: name, fx: stored ?? null },
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Track fields an instrument word sets beyond `instrument`: a rig alias
210
+ * (`jangle`, `punk`, `gtr-metal`…) also loads its rig over `fx`, keeping
211
+ * the track's other effects. Empty for every other word.
212
+ */
213
+ export function rigWordPatch(
214
+ word: string,
215
+ fx: TrackFx | undefined,
216
+ ): Readonly<{ fx?: TrackFx | null; reverb?: TrackPatch["reverb"] }> {
217
+ const rig = resolveInstrumentWord(word)?.fx;
218
+ if (!rig || !Object.prototype.hasOwnProperty.call(RIG_PRESETS, rig))
219
+ return {};
220
+ return rigPatch({ fx }, rig);
221
+ }
222
+
223
+ /**
224
+ * The track patch for rig `name` (or `"reset"`): the stages and companion
225
+ * effects, plus `rig spring`'s short room. A reverb a previous rig set (and
226
+ * nobody changed since) is removed when the new rig has none.
227
+ */
228
+ function rigPatch(
229
+ track: Readonly<{ fx?: TrackFx; reverb?: unknown }>,
230
+ name: string,
231
+ ): Readonly<{ fx: TrackFx | null; reverb?: TrackPatch["reverb"] }> {
232
+ const reverb = name === "reset" ? undefined : rigReverb(name);
233
+ return {
234
+ fx: applyRigPreset(track.fx, name) ?? null,
235
+ ...(reverb
236
+ ? { reverb: reverb as unknown as TrackPatch["reverb"] }
237
+ : isRigReverb(track.reverb)
238
+ ? { reverb: null }
239
+ : {}),
240
+ };
241
+ }
242
+
243
+ /**
244
+ * A new track's fields for instrument word `word`: a guitar alias gets its
245
+ * guitar voice and rig, any other word `{}` (callers pick the instrument).
246
+ */
247
+ export function rigTrackFields(word: string): Readonly<{
248
+ instrument?: string;
249
+ string?: { preset: string };
250
+ fx?: TrackFx;
251
+ reverb?: TrackPatch["reverb"];
252
+ }> {
253
+ const meaning = resolveInstrumentWord(word);
254
+ if (!meaning?.fx) return {};
255
+ const { fx, reverb } = rigWordPatch(word, undefined);
256
+ return {
257
+ instrument: meaning.instrument,
258
+ ...(meaning.field === "string" && meaning.preset
259
+ ? { string: { preset: meaning.preset } }
260
+ : {}),
261
+ ...(fx ? { fx } : {}),
262
+ ...(reverb ? { reverb } : {}),
263
+ };
264
+ }
@@ -38,7 +38,8 @@ export type SampleCommand =
38
38
  }>;
39
39
 
40
40
  /** A sample control value: a number, a switch, a unit, or null to unset. */
41
- export type SampleControlValue = number | string | boolean | null;
41
+ export type SampleControlValue =
42
+ number | string | boolean | null | readonly number[];
42
43
 
43
44
  /**
44
45
  * Per-voice sample controls `/sample set` and `set_sample` change, by
@@ -59,6 +60,15 @@ export const SAMPLE_CONTROLS = Object.freeze({
59
60
  accelerate: "rate ramps by this × over the voice (−8..8)",
60
61
  squiz: "pitch-raise ratio per zero-crossing cycle (1..32)",
61
62
  cut: "choke group name: a new hit stops the previous one",
63
+ bpm: "the sample's own tempo: it follows the song's tempo map",
64
+ fitmode: "how a fitted sample changes time: repitch, beats or tones",
65
+ len: "the window lasts n beats of the song's tempo map",
66
+ shift: "pitch in semitones (−24..24), length unchanged",
67
+ formant: "formants in semitones with shift: 0 keeps the voice, off follows",
68
+ fadeTime: "release fade in seconds (0..2); alias fadeout",
69
+ fadeInTime: "attack fade in seconds (0..2); alias fadein",
70
+ vel: "velocity layer lo-hi (MIDI 0-127): plays only for those velocities",
71
+ rr: "round-robin group name: voices in a group take turns",
62
72
  });
63
73
  export type SampleControl = keyof typeof SAMPLE_CONTROLS;
64
74
 
@@ -67,6 +77,8 @@ const SAMPLE_ALIASES: Readonly<Record<string, string>> = {
67
77
  loope: "loopEnd",
68
78
  legato: "clip",
69
79
  choke: "cut",
80
+ fadeout: "fadeTime",
81
+ fadein: "fadeInTime",
70
82
  };
71
83
 
72
84
  function controlName(raw: string): SampleControl | undefined {
@@ -173,6 +185,18 @@ export function setSampleControls(
173
185
  continue;
174
186
  }
175
187
  if (name === "cut" && typeof value === "number") value = `cut${value}`;
188
+ if (name === "rr" && typeof value === "number") value = `rr${value}`;
189
+ if (name === "vel" && value !== null) {
190
+ const range = parseVelocityRange(value);
191
+ if (!range)
192
+ return {
193
+ ok: false,
194
+ message: "sample · vel needs lo-hi MIDI velocities, e.g. vel 0-63",
195
+ };
196
+ next.vel = range;
197
+ changed.push(`vel ${range[0]}-${range[1]}`);
198
+ continue;
199
+ }
176
200
  if (value === null) delete next[field];
177
201
  else next[field] = value;
178
202
  changed.push(`${raw} ${value === null ? "off" : String(value)}`);
@@ -200,10 +224,24 @@ export function setSampleControls(
200
224
  return {
201
225
  ok: true,
202
226
  next: updated,
203
- message: `sample · ${trackId}/${voice} · ${changed.join(" · ")}`,
227
+ message: `sample · ${trackId}/${voice} · ${changed.join(" · ")}${layerHint(updated, trackId, voice)}`,
204
228
  };
205
229
  }
206
230
 
231
+ /** `0-63`, `0..63`, `64` (one velocity) → [lo, hi]; else undefined. */
232
+ export function parseVelocityRange(
233
+ value: SampleControlValue | readonly number[],
234
+ ): [number, number] | undefined {
235
+ if (Array.isArray(value))
236
+ return value.length === 2 && value.every((v) => typeof v === "number")
237
+ ? [value[0] as number, value[1] as number]
238
+ : undefined;
239
+ if (typeof value === "number") return [value, value];
240
+ if (typeof value !== "string") return undefined;
241
+ const match = value.trim().match(/^(\d{1,3})\s*(?:-|\.\.|,)\s*(\d{1,3})$/);
242
+ return match ? [Number(match[1]), Number(match[2])] : undefined;
243
+ }
244
+
207
245
  function unquote(value: string): string {
208
246
  const quoted = value.match(/^(["'])(.*)\1$/);
209
247
  return quoted ? quoted[2]! : value;
@@ -369,6 +407,18 @@ export function freeVoiceName(
369
407
  }
370
408
  }
371
409
 
410
+ /**
411
+ * One-shot velocity layers swap only within a round-robin group: a kit
412
+ * voice with `vel` and no `rr` keeps its own pad, so say how to join them.
413
+ */
414
+ function layerHint(score: TrackScore, trackId: string, voice: string): string {
415
+ const sampler = score.tracks.find((item) => item.id === trackId)?.sampler;
416
+ const ref = sampler?.voices[voice];
417
+ if (sampler?.mode !== "oneshot" || !ref?.vel || ref.rr !== undefined)
418
+ return "";
419
+ return ` · one-shot layers share a pad only within an rr group: sample ${voice} rr <group> on each layer`;
420
+ }
421
+
372
422
  export class SamplePlacementError extends Error {}
373
423
 
374
424
  /**
@@ -0,0 +1,119 @@
1
+ /**
2
+ * `shift <semitones> [formant keep|follow|<n>] [<voice>]` and
3
+ * `fade [in|out] <seconds> [<voice>]` (0.6.1): pitch a sampler voice
4
+ * without changing its length, and set its fade in and out. They act on
5
+ * the focused sampler track's voice (named, or its only one) through
6
+ * `setSampleControls`, so validation and printing stay the sampler's.
7
+ */
8
+ import type { TrackScore } from "../../core/score.ts";
9
+ import { fitVoice } from "./fit.ts";
10
+ import {
11
+ setSampleControls,
12
+ type SampleControlValue,
13
+ type SetSampleResult,
14
+ } from "./sample.ts";
15
+
16
+ export type ShiftCommand =
17
+ | Readonly<{
18
+ kind: "shift";
19
+ /** Semitones; null clears shift and formant. */
20
+ semitones: number | null;
21
+ /** 0 keeps the formants, null follows the pitch, undefined leaves it. */
22
+ formant?: number | null;
23
+ voice?: string;
24
+ }>
25
+ | Readonly<{
26
+ kind: "fade";
27
+ /** `in` is fadeInTime, `out` fadeTime, `both` sets each. */
28
+ edge: "in" | "out" | "both";
29
+ seconds: number | null;
30
+ voice?: string;
31
+ }>;
32
+
33
+ const VOICE = "([a-z][a-z0-9_]{0,31})";
34
+ const NUMBER = "([+-]?\\d+(?:\\.\\d+)?|off|none)";
35
+
36
+ /** Parse `shift ...` and `fade ...` (with or without the slash). */
37
+ export function parseShiftCommand(command: string): ShiftCommand | undefined {
38
+ const text = command.trim();
39
+ const shift = text.match(
40
+ new RegExp(
41
+ `^/?shift\\s+${NUMBER}(?:\\s+formant\\s+(keep|follow|[+-]?\\d+(?:\\.\\d+)?))?(?:\\s+(?:voice\\s+)?${VOICE})?$`,
42
+ "i",
43
+ ),
44
+ );
45
+ if (shift) {
46
+ const raw = shift[1]!.toLowerCase();
47
+ const formant = shift[2]?.toLowerCase();
48
+ return {
49
+ kind: "shift",
50
+ semitones: raw === "off" || raw === "none" ? null : Number(raw),
51
+ ...(formant === undefined
52
+ ? {}
53
+ : {
54
+ formant:
55
+ formant === "keep"
56
+ ? 0
57
+ : formant === "follow"
58
+ ? null
59
+ : Number(formant),
60
+ }),
61
+ ...(shift[3] ? { voice: shift[3].toLowerCase() } : {}),
62
+ };
63
+ }
64
+ const fade = text.match(
65
+ new RegExp(
66
+ `^/?fade(?:\\s+(in|out))?\\s+${NUMBER}(?:\\s+(?:voice\\s+)?${VOICE})?$`,
67
+ "i",
68
+ ),
69
+ );
70
+ if (!fade) return undefined;
71
+ const raw = fade[2]!.toLowerCase();
72
+ return {
73
+ kind: "fade",
74
+ edge: (fade[1]?.toLowerCase() as "in" | "out" | undefined) ?? "both",
75
+ seconds: raw === "off" || raw === "none" ? null : Number(raw),
76
+ ...(fade[3] ? { voice: fade[3].toLowerCase() } : {}),
77
+ };
78
+ }
79
+
80
+ /** Apply a parsed `shift` or `fade` to the focused track's voice. */
81
+ export function applyShiftCommand(
82
+ score: TrackScore,
83
+ trackId: string,
84
+ command: ShiftCommand,
85
+ ): SetSampleResult {
86
+ const target = fitVoice(score, trackId, command.voice);
87
+ if ("error" in target)
88
+ return {
89
+ ok: false,
90
+ message: target.error.replace(/^fit · /, `${command.kind} · `),
91
+ };
92
+ const values: Record<string, SampleControlValue> = {};
93
+ if (command.kind === "shift") {
94
+ // `shift off` clears the shift and its formant setting. `shift 0`
95
+ // clears the shift and a kept formant (0, a no-op without a shift) but
96
+ // keeps a formant move, so `shift 0 formant 3` is a formant-only move
97
+ // and stepping the shift through 0 keeps a stored formant.
98
+ if (command.semitones === null) {
99
+ values.shift = null;
100
+ values.formant = null;
101
+ } else {
102
+ values.shift = command.semitones === 0 ? null : command.semitones;
103
+ const formant =
104
+ command.formant !== undefined
105
+ ? command.formant
106
+ : score.tracks.find((track) => track.id === trackId)?.sampler?.voices[
107
+ target.voice
108
+ ]?.formant;
109
+ if (command.semitones === 0 && (formant === 0 || formant === null))
110
+ values.formant = null;
111
+ else if (command.formant !== undefined) values.formant = command.formant;
112
+ }
113
+ } else {
114
+ const seconds = command.seconds === 0 ? null : command.seconds;
115
+ if (command.edge !== "out") values.fadeInTime = seconds;
116
+ if (command.edge !== "in") values.fadeTime = seconds;
117
+ }
118
+ return setSampleControls(score, trackId, target.voice, values);
119
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The `string` prompt command: the focused track's plucked-string engine
3
+ * (core/strings.ts, `Track.string`).
4
+ *
5
+ * string list this track's preset and overrides
6
+ * string presets the plucked presets
7
+ * string <preset> | preset <name> play a preset (instrument "string")
8
+ * string <param> <value> [<param> <value>…] override parameters
9
+ * string <param> off back to the preset's value
10
+ * string reset drop the overrides, keep the preset
11
+ * string off back to the track's synth voice
12
+ * bowed play the cello preset (bowed strings)
13
+ * bowed <preset> a bowed preset (violin viola cello …)
14
+ * bowed presets the bowed presets
15
+ *
16
+ * `string sitar`, `string buzz 0.8 sym 0.5`, `string ring 6`. Each command
17
+ * is one `updateTrack` revision and one undo step.
18
+ */
19
+ import { FxValidationError } from "../../core/params.ts";
20
+ import {
21
+ ScoreValidationError,
22
+ updateTrack,
23
+ type TrackScore,
24
+ } from "../../core/score.ts";
25
+ import {
26
+ BOWED_PRESET_NAMES,
27
+ DEFAULT_BOWED_PRESET,
28
+ DEFAULT_STRING_PRESET,
29
+ isStringTrack,
30
+ normalizeString,
31
+ STRING_INSTRUMENT,
32
+ STRING_PARAMS,
33
+ STRING_PRESET_NAMES,
34
+ STRING_PRESETS,
35
+ STRING_SIMPLE_PARAMS,
36
+ stringParamName,
37
+ stringPresetName,
38
+ stringPresetOf,
39
+ type TrackString,
40
+ } from "../../core/strings.ts";
41
+ import { parseParamValue } from "./fx.ts";
42
+
43
+ export type StringCommand =
44
+ | { type: "string-list" }
45
+ | { type: "string-presets"; bowed?: boolean }
46
+ | { type: "string-reset" }
47
+ | { type: "string-off" }
48
+ | { type: "string-preset"; preset: string }
49
+ | {
50
+ type: "string-set";
51
+ /** `null` unsets a parameter (the preset's value again). */
52
+ values: Readonly<Record<string, number | string | null>>;
53
+ };
54
+
55
+ export function parseStringCommand(prompt: string): StringCommand | undefined {
56
+ const words = prompt.trim().split(/\s+/);
57
+ if (words[0]?.toLowerCase() === "bowed") return parseBowed(words.slice(1));
58
+ if (words[0]?.toLowerCase() !== "string") return undefined;
59
+ if (words.length === 1) return { type: "string-list" };
60
+ if (prompt.length > 1_024) return undefined;
61
+ const rest = words.slice(1).map((word) => word.toLowerCase());
62
+ if (rest.length === 1) {
63
+ if (rest[0] === "reset") return { type: "string-reset" };
64
+ if (rest[0] === "off") return { type: "string-off" };
65
+ if (rest[0] === "presets" || rest[0] === "list")
66
+ return { type: "string-presets" };
67
+ const preset = stringPresetName(rest[0]!);
68
+ return preset ? { type: "string-preset", preset } : undefined;
69
+ }
70
+ if (rest[0] === "preset") {
71
+ const preset = rest.length === 2 ? stringPresetName(rest[1]!) : undefined;
72
+ return preset ? { type: "string-preset", preset } : undefined;
73
+ }
74
+ if (rest.length % 2 !== 0) return undefined;
75
+ const values: Record<string, number | string | null> = {};
76
+ for (let index = 0; index < rest.length; index += 2) {
77
+ const name = stringParamName(rest[index]!);
78
+ if (!name) return undefined;
79
+ const word = rest[index + 1]!;
80
+ if (word === "off" || word === "unset") {
81
+ values[name] = null;
82
+ continue;
83
+ }
84
+ const value = parseParamValue(STRING_PARAMS[name]!, word);
85
+ if (value === undefined || typeof value === "boolean") return undefined;
86
+ values[name] = value;
87
+ }
88
+ return { type: "string-set", values };
89
+ }
90
+
91
+ /**
92
+ * `bowed` (0.6.1): the string engine's bowed family. `bowed` plays cello,
93
+ * `bowed <preset>` a bowed preset, `bowed presets` lists them, and
94
+ * `bowed <param> <value>…` is `string <param> <value>…`.
95
+ */
96
+ function parseBowed(words: string[]): StringCommand | undefined {
97
+ if (words.length === 0)
98
+ return { type: "string-preset", preset: DEFAULT_BOWED_PRESET };
99
+ const rest = words.map((word) => word.toLowerCase());
100
+ if (rest.length === 1 && (rest[0] === "presets" || rest[0] === "list"))
101
+ return { type: "string-presets", bowed: true };
102
+ if (rest.length === 1) {
103
+ const preset = stringPresetName(rest[0]!);
104
+ return preset && BOWED_PRESET_NAMES.includes(preset)
105
+ ? { type: "string-preset", preset }
106
+ : undefined;
107
+ }
108
+ return parseStringCommand(`string ${rest.join(" ")}`);
109
+ }
110
+
111
+ /** `sitar · buzz 0.8 · sym 0.5`, or `nylon` for a bare preset. */
112
+ export function describeString(settings: TrackString | undefined): string {
113
+ const parts = [stringPresetOf(settings)];
114
+ for (const [key, value] of Object.entries(settings ?? {}))
115
+ if (key !== "preset" && value !== undefined) parts.push(`${key} ${value}`);
116
+ return parts.join(" · ");
117
+ }
118
+
119
+ export type StringResult = Readonly<{
120
+ ok: boolean;
121
+ message: string;
122
+ next?: TrackScore;
123
+ kind?: string;
124
+ payload?: Record<string, unknown>;
125
+ }>;
126
+
127
+ export function applyStringCommand(
128
+ score: TrackScore,
129
+ trackId: string,
130
+ command: StringCommand,
131
+ ): StringResult {
132
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
133
+ if (!track) return { ok: false, message: `no track · ${trackId}` };
134
+ if (command.type === "string-presets")
135
+ return {
136
+ ok: true,
137
+ message: `${command.bowed ? "bowed" : "string"} presets · ${(command.bowed ? BOWED_PRESET_NAMES : STRING_PRESET_NAMES).map((name) => `${name} (${STRING_PRESETS[name]!.doc})`).join(" · ")}`,
138
+ };
139
+ if (track.sampler || track.kit)
140
+ return {
141
+ ok: false,
142
+ message: `string · ${trackId} is a ${track.sampler ? "sampler" : "drum"} track; the string engine plays pitched notes`,
143
+ };
144
+ const active = isStringTrack(track);
145
+ if (command.type === "string-list")
146
+ return {
147
+ ok: true,
148
+ message: active
149
+ ? `string · ${describeString(track.string)} · basics ${STRING_SIMPLE_PARAMS.join(" ")}`
150
+ : `string · off (${track.instrument}) · string ${DEFAULT_STRING_PRESET} turns it on · string presets`,
151
+ };
152
+ let instrument = track.instrument;
153
+ let string: Record<string, unknown> | null;
154
+ if (command.type === "string-off") {
155
+ if (!active) return { ok: true, message: "string · already off" };
156
+ instrument = "pluck";
157
+ string = null;
158
+ } else if (command.type === "string-reset") {
159
+ if (!active) return { ok: true, message: "string · already off" };
160
+ string = { preset: stringPresetOf(track.string) };
161
+ } else if (command.type === "string-preset") {
162
+ instrument = STRING_INSTRUMENT;
163
+ string = { preset: command.preset };
164
+ } else {
165
+ instrument = STRING_INSTRUMENT;
166
+ string = active ? { ...track.string } : { preset: DEFAULT_STRING_PRESET };
167
+ for (const [key, value] of Object.entries(command.values)) {
168
+ if (value === null) delete string[key];
169
+ else string[key] = value;
170
+ }
171
+ }
172
+ let next: TrackScore;
173
+ try {
174
+ next = updateTrack(score, trackId, {
175
+ instrument,
176
+ string: string === null ? null : (normalizeString(string) ?? null),
177
+ });
178
+ } catch (error) {
179
+ if (
180
+ error instanceof ScoreValidationError ||
181
+ error instanceof FxValidationError
182
+ )
183
+ return { ok: false, message: `string · ${error.message}` };
184
+ throw error;
185
+ }
186
+ const stored = next.tracks.find((candidate) => candidate.id === trackId);
187
+ return {
188
+ ok: true,
189
+ message:
190
+ command.type === "string-off"
191
+ ? `string · off · ${instrument}`
192
+ : `string · ${describeString(stored?.string)}`,
193
+ next,
194
+ kind: "score.string",
195
+ payload: {
196
+ trackId,
197
+ instrument: stored?.instrument ?? instrument,
198
+ string: stored?.string ?? null,
199
+ },
200
+ };
201
+ }