@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
@@ -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" };
@@ -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,19 +902,30 @@ 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
- const downs = (pedal ?? []).filter((event) => event.state !== "up");
912
+ const downs = (pedal ?? []).filter((event) => event.state === "down");
913
+ const halves = (pedal ?? []).filter((event) => event.state === "half");
914
+ const held = [
915
+ ...(downs.length > 0 || halves.length === 0
916
+ ? [`${downs.length} down`]
917
+ : []),
918
+ ...(halves.length > 0 ? [`${halves.length} half`] : []),
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;
836
924
  return trackResult(
837
925
  score,
838
926
  track,
839
- { pedal: pedal ?? null },
840
- `pedal · ${pedal?.length ?? 0} event${pedal?.length === 1 ? "" : "s"} (${downs.length} down) · ${trackId}`,
927
+ { [field]: pedal ?? null },
928
+ `${label} · ${pedal?.length ?? 0} event${pedal?.length === 1 ? "" : "s"} (${held}) · ${trackId}${silent ? ` · no effect: ${silent}` : ""}`,
841
929
  );
842
930
  }
843
931
  case "velcurve": {
@@ -926,7 +1014,7 @@ export const EXPRESSION_USAGE = Object.freeze({
926
1014
  glide:
927
1015
  "glide <ms>|<s>s|off [legato|mono|poly] (track) · glide <ms>|off <target> (notes) · glide 60 mono · SDK glide: 0.06 (s)",
928
1016
  pedal:
929
- "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)",
930
1018
  velcurve:
931
1019
  "velcurve linear|soft|hard|fixed [<v 0..1>] · velcurve soft · velcurve fixed 0.8",
932
1020
  humanize:
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `/fitmode <mode>`, `/bpm <n>` and `/len <beats>` (0.6): fit a sampler
3
+ * voice to the song's time. They act on the focused sampler track's voice
4
+ * (named, or its only voice) through `setSampleControls`, so validation and
5
+ * printing stay the sampler's. `/fitmode` with no mode suggests one from
6
+ * the sample's content (`suggestFitMode`); song tempo stays `tempo <n>`.
7
+ */
8
+ import {
9
+ SAMPLE_FIT_MODES,
10
+ isSamplerInstrument,
11
+ samplerVoiceSlots,
12
+ type SampleFitMode,
13
+ type TrackScore,
14
+ } from "../../core/score.ts";
15
+ import { setSampleControls, type SetSampleResult } from "./sample.ts";
16
+
17
+ export type FitCommand = Readonly<{
18
+ control: "fitmode" | "bpm" | "len";
19
+ /** Undefined: `/fitmode` alone (suggest) or `off`-style unset is null. */
20
+ value: SampleFitMode | number | null | undefined;
21
+ voice?: string;
22
+ }>;
23
+
24
+ const VOICE = "([a-z][a-z0-9_]{0,31})";
25
+
26
+ /**
27
+ * Parse `/fitmode [mode|auto|off [voice]]`, `/bpm <n|off> [voice]`,
28
+ * `/len <beats|off> [voice]`. `fitmode` and `len` also work without the
29
+ * slash; a bare `bpm 120` stays the song's tempo word, so `/bpm` needs it.
30
+ */
31
+ export function parseFitCommand(command: string): FitCommand | undefined {
32
+ const text = command.trim();
33
+ const mode = text.match(
34
+ new RegExp(
35
+ `^/?fitmode(?:\\s+(repitch|beats|tones|auto|off|none)(?:\\s+${VOICE})?)?$`,
36
+ "i",
37
+ ),
38
+ );
39
+ if (mode) {
40
+ const raw = mode[1]?.toLowerCase();
41
+ return {
42
+ control: "fitmode",
43
+ value:
44
+ raw === undefined || raw === "auto"
45
+ ? undefined
46
+ : raw === "off" || raw === "none"
47
+ ? null
48
+ : (raw as SampleFitMode),
49
+ ...(mode[2] ? { voice: mode[2].toLowerCase() } : {}),
50
+ };
51
+ }
52
+ const number = text.match(
53
+ new RegExp(
54
+ `^(/bpm|/?len)\\s+(\\d+(?:\\.\\d+)?|off|none)(?:\\s+${VOICE})?$`,
55
+ "i",
56
+ ),
57
+ );
58
+ if (!number) return undefined;
59
+ const raw = number[2]!.toLowerCase();
60
+ return {
61
+ control: number[1]!.toLowerCase().replace("/", "") as "bpm" | "len",
62
+ value: raw === "off" || raw === "none" ? null : Number(raw),
63
+ ...(number[3] ? { voice: number[3].toLowerCase() } : {}),
64
+ };
65
+ }
66
+
67
+ /**
68
+ * The voice a fit command acts on: the named one, else the track's only
69
+ * voice (lowest slot first when it has several and none is named).
70
+ */
71
+ export function fitVoice(
72
+ score: TrackScore,
73
+ trackId: string,
74
+ voice: string | undefined,
75
+ ): { voice: string } | { error: string } {
76
+ const track = score.tracks.find((item) => item.id === trackId);
77
+ const sampler = track?.sampler;
78
+ if (!track || !sampler || !isSamplerInstrument(track.instrument))
79
+ return {
80
+ error: `fit · ${trackId} is not a sampler track · /sample <path> first · song tempo is tempo <bpm>`,
81
+ };
82
+ if (voice) return { voice };
83
+ const names = Object.keys(sampler.voices);
84
+ if (names.length === 1) return { voice: names[0]! };
85
+ const slots = samplerVoiceSlots(sampler);
86
+ const sorted = names.sort(
87
+ (a, b) => (slots.get(a) ?? 0) - (slots.get(b) ?? 0) || (a < b ? -1 : 1),
88
+ );
89
+ return {
90
+ error: `fit · ${trackId} has ${names.length} voices · name one: ${sorted.join(" ")}`,
91
+ };
92
+ }
93
+
94
+ /** Apply a parsed fit command with a known value (a suggested mode resolved). */
95
+ export function applyFitCommand(
96
+ score: TrackScore,
97
+ trackId: string,
98
+ command: FitCommand,
99
+ mode?: SampleFitMode,
100
+ ): SetSampleResult {
101
+ const target = fitVoice(score, trackId, command.voice);
102
+ if ("error" in target) return { ok: false, message: target.error };
103
+ const value = command.value === undefined ? (mode ?? null) : command.value;
104
+ if (command.control === "fitmode" && value !== null) {
105
+ const ref = score.tracks.find((t) => t.id === trackId)?.sampler?.voices[
106
+ target.voice
107
+ ];
108
+ if (
109
+ ref &&
110
+ value !== "repitch" &&
111
+ ref.bpm === undefined &&
112
+ ref.len === undefined &&
113
+ ref.fit !== true
114
+ )
115
+ return {
116
+ ok: false,
117
+ message: `fit · fitmode ${String(value)} needs the sample's tempo first · /bpm <n> or /len <beats>`,
118
+ };
119
+ }
120
+ const values: Record<string, number | string | null> = {
121
+ [command.control]: value,
122
+ };
123
+ // Unsetting bpm or len leaves a fitmode with nothing to fit: unset both.
124
+ if (value === null && command.control !== "fitmode") {
125
+ const ref = score.tracks.find((t) => t.id === trackId)?.sampler?.voices[
126
+ target.voice
127
+ ];
128
+ const other = command.control === "bpm" ? ref?.len : ref?.bpm;
129
+ if (ref?.fitmode && other === undefined && ref.fit !== true)
130
+ values.fitmode = null;
131
+ }
132
+ return setSampleControls(score, trackId, target.voice, values);
133
+ }
134
+
135
+ export const FIT_MODES = SAMPLE_FIT_MODES;
@@ -4,11 +4,12 @@
4
4
  * fx list the focused track's effects
5
5
  * fx <effect> on|off|reset enable with defaults, remove, reset
6
6
  * fx <effect> preset <name> load a named preset (FX_PRESETS)
7
+ * fx <effect> turn it on (same as `on`)
7
8
  * fx <effect> <param> <value> set one parameter (enables it)
8
9
  * fx <effect> <number> set its first parameter (`fx orbit 2`)
9
10
  * fx <effect> <param> <value> <param> <value>… several at once
10
11
  * fx reverb ir <impulse>|off convolution reverb (Strudel `ir`):
11
- * room|hall|plate, a pack sound or a
12
+ * room|hall|plate|reverse|gate|spring, a pack sound or a
12
13
  * project file; `fx ir hall` too
13
14
  *
14
15
  * Effects are every `FX_CHAIN` stage except pan (see core/fx.ts), plus
@@ -18,6 +19,7 @@
18
19
  * on/off/true/false; enums take one of their values. Each command is one
19
20
  * `updateTrack` revision and one undo step.
20
21
  */
22
+ import { nearestWord } from "../audio/instrument-check.ts";
21
23
  import {
22
24
  EFFECT_NAMES,
23
25
  FX_PRESETS,
@@ -146,6 +148,8 @@ export function parseFxCommand(prompt: string): FxCommand | undefined {
146
148
  if (effect === "reverb" && irWord(words[2]) && words.length === 4)
147
149
  return irCommand(words[3]!);
148
150
  const rest = words.slice(2).map((word) => word.toLowerCase());
151
+ // `fx wobble`, `fx double`: a bare effect name turns it on.
152
+ if (rest.length === 0) return { type: "fx-on", effect };
149
153
  if (rest.length === 1 && (rest[0] === "on" || rest[0] === "off"))
150
154
  return { type: rest[0] === "on" ? "fx-on" : "fx-off", effect };
151
155
  if (rest.length === 1 && rest[0] === "reset")
@@ -190,6 +194,31 @@ export function parseFxCommand(prompt: string): FxCommand | undefined {
190
194
  return { type: "fx-set", effect, values };
191
195
  }
192
196
 
197
+ /**
198
+ * A short `fx <word> …` whose word is no effect (`fx wobble on`, `fx dela
199
+ * mix 0.3`): the local answer, so a typo never goes to the agent. Longer
200
+ * free text after `fx` still does.
201
+ */
202
+ export function unknownFxMessage(prompt: string): string | undefined {
203
+ const words = prompt.trim().split(/\s+/);
204
+ if (words[0]?.toLowerCase() !== "fx" || words.length < 2) return undefined;
205
+ const name = words[1]!.toLowerCase();
206
+ if (parseEffectName(name) || ["ir", "iresponse", "amp"].includes(name))
207
+ return undefined;
208
+ const near = nearestWord(name, [
209
+ ...EFFECT_NAMES,
210
+ ...Object.keys(EFFECT_ALIASES),
211
+ "reverb",
212
+ ]);
213
+ const rest = words.slice(2).map((word) => word.toLowerCase());
214
+ const commandShaped =
215
+ rest.length <= 1 ||
216
+ ["on", "off", "reset", "preset"].includes(rest[0]!) ||
217
+ rest.every((word, index) => index % 2 === 0 || /^-?[\d.]+/.test(word));
218
+ if (!near && !commandShaped) return undefined;
219
+ return `unknown effect ${name.slice(0, 24)}${near ? ` · did you mean ${near}?` : ""} · effects ${EFFECT_NAMES.join(", ")}`;
220
+ }
221
+
193
222
  function irCommand(value: string): FxCommand | undefined {
194
223
  const lower = value.toLowerCase();
195
224
  if (lower === "off" || lower === "none") return { type: "fx-ir", ir: null };
@@ -208,6 +237,11 @@ export function effectDefaults(effect: EffectName): FxValues {
208
237
  if (!(spec.optional && spec.default === false)) out[key] = spec.default;
209
238
  // A zero `time` means "follow beats"; the canonical form omits it.
210
239
  if (effect === "delay") delete out.time;
240
+ // A head's sag follows its type and its noise gate is off until set.
241
+ if (effect === "head") {
242
+ delete out.sag;
243
+ delete out.gate;
244
+ }
211
245
  return out;
212
246
  }
213
247