@hraness/dawg 0.6.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 (93) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/DAWG.md +338 -51
  3. package/README.md +4 -4
  4. package/core/chords.ts +492 -4
  5. package/core/diff.ts +5 -0
  6. package/core/expression.ts +143 -6
  7. package/core/fx.ts +258 -6
  8. package/core/granular.ts +91 -0
  9. package/core/instruments.ts +161 -0
  10. package/core/keys.ts +573 -4
  11. package/core/resonators.ts +291 -3
  12. package/core/score.ts +349 -3
  13. package/core/sdk/print.ts +78 -3
  14. package/core/sdk/v1.ts +1320 -36
  15. package/core/sections.ts +12 -4
  16. package/core/strings.ts +237 -2
  17. package/core/winds.ts +652 -0
  18. package/guides/automation.md +1 -0
  19. package/guides/chords.md +3 -1
  20. package/guides/effects.md +3 -2
  21. package/guides/media.md +1 -1
  22. package/guides/performance.md +3 -0
  23. package/guides/resample.md +26 -0
  24. package/guides/sounds.md +7 -6
  25. package/package.json +1 -1
  26. package/src/agent/agent.ts +8 -2
  27. package/src/agent/chord-tools.ts +153 -1
  28. package/src/agent/expression-tools.ts +91 -0
  29. package/src/agent/ops.ts +4 -1
  30. package/src/agent/preview-tool.ts +2 -1
  31. package/src/agent/resample-tool.ts +131 -0
  32. package/src/agent/tools.ts +200 -15
  33. package/src/agent/xcb-agent.ts +8 -2
  34. package/src/audio/arrange.ts +21 -0
  35. package/src/audio/dsp/envelope.ts +161 -0
  36. package/src/audio/dsp/shift.ts +256 -0
  37. package/src/audio/effects/chain.ts +18 -3
  38. package/src/audio/effects/common.ts +14 -0
  39. package/src/audio/effects/convolution.ts +106 -1
  40. package/src/audio/effects/gaze.ts +354 -0
  41. package/src/audio/engine.ts +46 -5
  42. package/src/audio/fit.ts +45 -0
  43. package/src/audio/granular.ts +352 -21
  44. package/src/audio/instrument-check.ts +9 -5
  45. package/src/audio/instruments.ts +32 -3
  46. package/src/audio/keys/electric.ts +420 -0
  47. package/src/audio/keys/engine.ts +143 -19
  48. package/src/audio/keys/organ.ts +1335 -0
  49. package/src/audio/keys/piano.ts +50 -6
  50. package/src/audio/keys/sympathetic.ts +127 -0
  51. package/src/audio/live-worker.ts +3 -1
  52. package/src/audio/live.ts +136 -9
  53. package/src/audio/preview.ts +15 -1
  54. package/src/audio/resample.ts +285 -0
  55. package/src/audio/resonators.ts +10 -2
  56. package/src/audio/sampler.ts +168 -21
  57. package/src/audio/samples.ts +26 -1
  58. package/src/audio/strings/body.ts +29 -16
  59. package/src/audio/strings/bow.ts +656 -0
  60. package/src/audio/strings/engine.ts +266 -20
  61. package/src/audio/strings/measure.test-helpers.ts +2 -0
  62. package/src/audio/wav.ts +119 -19
  63. package/src/audio/winds/engine.ts +302 -0
  64. package/src/audio/winds/filters.ts +153 -0
  65. package/src/audio/winds/pitch.ts +104 -0
  66. package/src/audio/winds/trim.ts +80 -0
  67. package/src/audio/winds/trims.ts +917 -0
  68. package/src/audio/winds/voice.ts +479 -0
  69. package/src/commands/expression.ts +103 -22
  70. package/src/commands/fx.ts +4 -1
  71. package/src/commands/granular.ts +7 -2
  72. package/src/commands/help.ts +91 -8
  73. package/src/commands/keys.ts +287 -11
  74. package/src/commands/modal.ts +67 -4
  75. package/src/commands/resample.ts +281 -0
  76. package/src/commands/rig.ts +5 -1
  77. package/src/commands/sample.ts +49 -2
  78. package/src/commands/shift.ts +119 -0
  79. package/src/commands/string.ts +28 -2
  80. package/src/commands/strum.ts +473 -0
  81. package/src/commands/wind.ts +244 -0
  82. package/src/main.ts +129 -7
  83. package/src/project/check.ts +5 -1
  84. package/src/render.ts +16 -0
  85. package/src/session/presence.ts +16 -3
  86. package/src/tui/audition.ts +1 -1
  87. package/src/tui/menu.ts +637 -35
  88. package/src/tui/modal-menu.ts +34 -7
  89. package/src/tui/performance-menu.ts +42 -0
  90. package/src/tui/play-chords.ts +104 -3
  91. package/src/tui/play-mode.ts +1 -0
  92. package/src/tui/play-session.ts +34 -9
  93. package/src/tui/wind-menu.ts +144 -0
@@ -14,6 +14,15 @@ import {
14
14
  type TrackModal,
15
15
  } from "../../core/resonators.ts";
16
16
  import { applyModalCommand, type ModalCommand } from "../commands/modal.ts";
17
+ import { applyWindCommand, type WindCommand } from "../commands/wind.ts";
18
+ import {
19
+ type TrackWind,
20
+ WIND_ALIASES,
21
+ WIND_PARAMS,
22
+ WIND_PRESET_NAMES,
23
+ windParamName,
24
+ windPresetFor,
25
+ } from "../../core/winds.ts";
17
26
  import {
18
27
  AUTOMATION_PARAMETERS,
19
28
  automationPoints,
@@ -46,9 +55,11 @@ import {
46
55
  } from "../commands/keys.ts";
47
56
  import {
48
57
  KEYS_PRESETS,
58
+ ORGAN_ALIASES,
49
59
  KEYS_SIMPLE,
50
60
  isKeysPreset,
51
61
  keysParamName,
62
+ pianoWrite,
52
63
  } from "../../core/keys.ts";
53
64
  import {
54
65
  setSampleControls,
@@ -96,6 +107,7 @@ import { DRUM_TOOLS } from "./drum-tools.ts";
96
107
  import { TIME_TOOLS } from "./time-tools.ts";
97
108
  import { SECTION_TOOLS } from "./section-tools.ts";
98
109
  import { GRANULAR_TOOLS } from "./granular-tools.ts";
110
+ import { RESAMPLE_TOOLS } from "./resample-tool.ts";
99
111
  import type { MediaResult, MediaRunContext } from "../media/types.ts";
100
112
  import { instrumentPatch, pitchToMidi } from "./ops.ts";
101
113
  import { TUNING_LIMITS } from "../../core/tuning.ts";
@@ -474,7 +486,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
474
486
  {
475
487
  name: "set_instrument",
476
488
  description:
477
- "Change a track's instrument voice. Mallets and bells (modal): modal (marimba) vibes xylophone glock celesta chimes kalimba mbira steelpan bowl gong timpani; set_modal shapes them.",
489
+ "Change a track's instrument voice. Mallets and bells (modal): modal (marimba) vibes xylophone glock celesta chimes kalimba mbira steelpan bowl gong timpani, gamelan saron gangsa bonang …; set_modal shapes them. Electric keys: epiano suitcase dyno wurli clav funkclav (set_keys). Winds and brass (wind engine): flute recorder clarinet oboe bassoon sax trumpet trombone tuba horn …; set_wind shapes them.",
478
490
  parameters: {
479
491
  type: "object",
480
492
  properties: {
@@ -490,7 +502,8 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
490
502
  const track = context.score.tracks.find((t) => t.id === trackId);
491
503
  if (track && typeof args.instrument === "string") {
492
504
  const word = args.instrument.toLowerCase();
493
- const preset = word === "piano" ? "grand" : word;
505
+ // `rhodes`, `wurlitzer`, `clavinet` name their electric presets.
506
+ const preset = pianoWrite(word)?.preset ?? word;
494
507
  if (isKeysPreset(preset)) {
495
508
  const patch = keysPresetPatch(track, preset);
496
509
  return {
@@ -513,7 +526,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
513
526
  summary:
514
527
  patch.instrument === "marimba" && !patch.modal
515
528
  ? `${trackId} → marimba (legacy tone) · set_modal preset marimba for the mallet engine`
516
- : `${trackId} → ${patch.string?.preset ?? patch.modal?.preset ?? patch.instrument}`,
529
+ : `${trackId} → ${patch.string?.preset ?? patch.modal?.preset ?? patch.wind?.preset ?? patch.instrument}`,
517
530
  };
518
531
  },
519
532
  },
@@ -587,7 +600,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
587
600
  parameter: {
588
601
  type: "string",
589
602
  description:
590
- "volume, pan, filter, resonance, delay-feedback, delay-mix, or <effect>-<param>",
603
+ "volume, pan, filter, resonance, delay-feedback, delay-mix, <effect>-<param>, or grain-<param> on a granular track",
591
604
  },
592
605
  mode: { type: "string", enum: ["replace", "merge"] },
593
606
  points: {
@@ -713,7 +726,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
713
726
  {
714
727
  name: "set_rig",
715
728
  description:
716
- "Guitar rig on a track (stomp → amp head with noise gate → speaker cab, before tremolo). rig loads a whole rig (clean crunch punk ragged lead metal fuzz octave funk wah bachata spring bassdrive reese jangle alt) or reset removes it; stomp/head/cab set stage params (stomp type fuzz|face|od|rat|octave gain tone level; head type clean|chime|crunch|lead|high|solid|bass gain bass mid treble presence master sag gate(dB); cab type 1x12|2x12|4x12|1x10|open|8x10|1x15|di mic), or null removes a stage. Heads are level-matched. `amp` is Strudel gain, not this.",
729
+ "Guitar rig on a track (stomp → amp head with noise gate → speaker cab, before tremolo). rig loads a whole rig (clean crunch punk ragged lead metal fuzz octave funk wah bachata spring bassdrive reese jangle alt, and the shoegaze rigs shoegaze glide dreampop swell ebow that add wobble/bloom/swell/double and a long reverb) or reset removes it; stomp/head/cab set stage params (stomp type fuzz|face|od|rat|octave gain tone level; head type clean|chime|crunch|lead|high|solid|bass gain bass mid treble presence master sag gate(dB); cab type 1x12|2x12|4x12|1x10|open|8x10|1x15|di mic), or null removes a stage. Heads are level-matched. `amp` is Strudel gain, not this.",
717
730
  parameters: {
718
731
  type: "object",
719
732
  properties: {
@@ -844,7 +857,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
844
857
  {
845
858
  name: "set_string",
846
859
  description:
847
- "Make a track a plucked string (physical model): preset picks the instrument; params override it (ring s, bright, damp, pos, mute, buzz = jawari, body, sym = sympathetic strings, stiff, exciter pick|finger|hammer|noise); null unsets one. reset keeps the preset and drops overrides; off returns the track to a plain pluck voice.",
860
+ "Make a track a plucked, struck or bowed string (physical model). Bowed presets (exciter bow): violin viola cello contrabass fiddle erhu kamancheh, sections violins violas cellos contrabasses, pizz trem. preset picks the instrument; params override it (ring s, bright, damp, pos, mute, buzz = jawari, body, sym = sympathetic strings, stiff, exciter pick|finger|hammer|noise|bow); null unsets one. Bowed presets take pressure speed attack vib vibmod vibdelay tremhz sord dyn. reset keeps the preset and drops overrides; off returns the track to a plain pluck voice.",
848
861
  parameters: {
849
862
  type: "object",
850
863
  properties: {
@@ -886,17 +899,25 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
886
899
  },
887
900
  {
888
901
  name: "set_keys",
889
- description: `Shape a modelled piano: preset (instrument, keys and its effects), params ${KEYS_SIMPLE.join(" ")}… (null unsets; DAWG.md lists all), or reset. Stored "piano" stays legacy; set_instrument piano writes grand.`,
902
+ description: `Shape a modelled piano, electric keys (epiano wurli clav) or organ: preset (instrument, keys and its effects), params ${KEYS_SIMPLE.join(" ")}… (pianos: sym 0.5 adds sympathetic bloom under the sustain pedal, good for Chopin and Debussy; epiano: bark bell tone vibe; wurli: trem; clav: pickup mute; null unsets; DAWG.md lists all), or reset. Stored "piano" stays legacy; set_instrument piano writes grand. Organs (not "organ", a legacy synth): presets tonewheel combo pipe… (aliases hammond b3 farfisa church); drawbars "888800008", registers "08880", stops (plenum, flute8…), rotary; params perc percdecay percvol click scanner drive (tonewheel), voice vib vibmod drive (combo), chiff wind trem (pipe). A row the family does not read is refused.`,
890
903
  parameters: {
891
904
  type: "object",
892
905
  properties: {
893
906
  trackId: trackIdSchema,
894
- preset: { type: "string", enum: Object.keys(KEYS_PRESETS) },
907
+ preset: {
908
+ type: "string",
909
+ enum: [...Object.keys(KEYS_PRESETS), ...Object.keys(ORGAN_ALIASES)],
910
+ },
895
911
  reset: { type: "boolean" },
896
912
  params: {
897
913
  type: "object",
898
914
  additionalProperties: { type: ["number", "string", "null"] },
899
915
  },
916
+ // f061-organ
917
+ drawbars: { type: "string", pattern: "^[0-8]{9}$" },
918
+ registers: { type: "string", pattern: "^[0-8]{5}$" },
919
+ stops: { type: ["array", "string"], items: { type: "string" } },
920
+ rotary: { type: "string", enum: ["slow", "fast", "stop"] },
900
921
  },
901
922
  additionalProperties: false,
902
923
  },
@@ -935,7 +956,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
935
956
  {
936
957
  name: "set_modal",
937
958
  description:
938
- "Mallets and bells on the modal engine: preset (marimba vibes xylophone glock celesta chimes kalimba mbira steelpan bowl gong timpani) switches the voice and keeps overrides; mallet yarn|cord|rubber|plastic|brass; params sets MODAL_PARAMS (hardness position ring tilt release damp motor motordepth ombak buzz click strikebend strikedecay gain), null returns one to the preset; reset clears overrides. Turns the track into instrument modal.",
959
+ "Mallets and bells on the modal engine: preset (marimba vibes xylophone glock celesta chimes kalimba mbira steelpan bowl gong timpani) switches the voice and keeps overrides (gamelan: crotales musicbox toypiano saron demung slenthem gangsa gender bonang kenong kethuk kempul; frame drums daf bodhran tabla); mallet yarn|cord|rubber|plastic|brass; params sets MODAL_PARAMS (hardness position ring tilt release damp motor motordepth ombak buzz click strikebend strikedecay gain), null returns one to the preset; pair names a partner modal track: this track (pengisep) sounds ombak Hz above it and the partner (pengumbang) gets ombak 0, null unpairs; reset clears overrides. Turns the track into instrument modal.",
939
960
  parameters: {
940
961
  type: "object",
941
962
  properties: {
@@ -945,6 +966,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
945
966
  enum: [...MODAL_PRESET_NAMES, ...Object.keys(MODAL_ALIASES)],
946
967
  },
947
968
  mallet: { type: "string", enum: [...MODAL_MALLET_NAMES] },
969
+ pair: { type: ["string", "null"] },
948
970
  reset: { type: "boolean" },
949
971
  params: {
950
972
  type: "object",
@@ -974,6 +996,11 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
974
996
  throw new ToolArgumentError("mallet must be a string");
975
997
  values.mallet = args.mallet;
976
998
  }
999
+ if (args.pair !== undefined) {
1000
+ if (args.pair !== null && typeof args.pair !== "string")
1001
+ throw new ToolArgumentError("pair must be a track id or null");
1002
+ values.pair = args.pair;
1003
+ }
977
1004
  if (args.params !== undefined) {
978
1005
  if (
979
1006
  typeof args.params !== "object" ||
@@ -1019,9 +1046,20 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
1019
1046
  messages.push(result.message);
1020
1047
  }
1021
1048
  const next = score.tracks.find((t) => t.id === trackId)!;
1049
+ // A pair also sets the partner (pengumbang) to ombak 0.
1050
+ const partners = score.tracks.filter(
1051
+ (t) =>
1052
+ t.id !== trackId &&
1053
+ t.modal !== context.score.tracks.find((o) => o.id === t.id)?.modal,
1054
+ );
1022
1055
  return {
1023
1056
  kind: "score",
1024
1057
  operations: [
1058
+ ...partners.map((t) => ({
1059
+ type: "updateTrack" as const,
1060
+ trackId: t.id,
1061
+ patch: { modal: t.modal ?? null },
1062
+ })),
1025
1063
  {
1026
1064
  type: "updateTrack",
1027
1065
  trackId,
@@ -1033,10 +1071,111 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
1033
1071
  };
1034
1072
  },
1035
1073
  },
1074
+ {
1075
+ name: "set_wind",
1076
+ description:
1077
+ "Winds and brass on the wind engine (breath-driven waveguides): preset (flute recorder whistle ney shakuhachi panpipe suling bansuri clarinet bassclarinet oboe bassoon sax altosax barisax trumpet harmon plunger trombone tuba horn) switches the voice and keeps overrides; params sets WIND_PARAMS (breath bright mute players growl noise attack release vib vibmod reed stopped wah wahenv flutter gain model), null returns one to the preset; players 2..8 is a section that spreads over chord tones; reset clears overrides; off returns to the legacy wind tone. Lines slur by default (a note that starts while one note is held is not re-tongued). Turns the track into the wind engine.",
1078
+ parameters: {
1079
+ type: "object",
1080
+ properties: {
1081
+ trackId: trackIdSchema,
1082
+ preset: {
1083
+ type: "string",
1084
+ enum: [...WIND_PRESET_NAMES, ...Object.keys(WIND_ALIASES)],
1085
+ },
1086
+ reset: { type: "boolean" },
1087
+ off: { type: "boolean" },
1088
+ params: {
1089
+ type: "object",
1090
+ additionalProperties: {
1091
+ type: ["number", "string", "boolean", "null"],
1092
+ },
1093
+ },
1094
+ },
1095
+ additionalProperties: false,
1096
+ },
1097
+ plan(args, context) {
1098
+ const trackId = targetTrack(args, context);
1099
+ const commands: WindCommand[] = [];
1100
+ if (args.off === true) commands.push({ type: "wind-off" });
1101
+ if (args.reset === true) commands.push({ type: "wind-reset" });
1102
+ if (args.preset !== undefined) {
1103
+ const preset =
1104
+ typeof args.preset === "string"
1105
+ ? windPresetFor(args.preset)
1106
+ : undefined;
1107
+ if (!preset)
1108
+ throw new ToolArgumentError(
1109
+ `preset must be one of ${WIND_PRESET_NAMES.join(", ")}`,
1110
+ );
1111
+ commands.push({ type: "wind-preset", preset });
1112
+ }
1113
+ const values: Record<string, number | string | boolean | null> = {};
1114
+ if (args.params !== undefined) {
1115
+ if (
1116
+ typeof args.params !== "object" ||
1117
+ args.params === null ||
1118
+ Array.isArray(args.params)
1119
+ )
1120
+ throw new ToolArgumentError("params must be an object");
1121
+ for (const [key, value] of Object.entries(args.params)) {
1122
+ const name = windParamName(key);
1123
+ if (!name)
1124
+ throw new ToolArgumentError(
1125
+ `wind has no parameter ${key} (${Object.keys(WIND_PARAMS).join(" ")})`,
1126
+ );
1127
+ if (
1128
+ value !== null &&
1129
+ typeof value !== "number" &&
1130
+ typeof value !== "string" &&
1131
+ typeof value !== "boolean"
1132
+ )
1133
+ throw new ToolArgumentError(
1134
+ `${key} must be a number, string or boolean`,
1135
+ );
1136
+ values[name] = value;
1137
+ }
1138
+ }
1139
+ if (Object.keys(values).length > 0)
1140
+ commands.push({ type: "wind-set", values });
1141
+ if (commands.length === 0) {
1142
+ // An empty call turns the track into the wind engine.
1143
+ const current = context.score.tracks.find((t) => t.id === trackId);
1144
+ commands.push({
1145
+ type: "wind-preset",
1146
+ preset:
1147
+ current?.instrument === "wind" && current.wind?.preset
1148
+ ? current.wind.preset
1149
+ : "flute",
1150
+ });
1151
+ }
1152
+ let score = context.score;
1153
+ const messages: string[] = [];
1154
+ for (const command of commands) {
1155
+ const result = applyWindCommand(score, trackId, command);
1156
+ if (!result.ok) throw new ToolArgumentError(result.message);
1157
+ if (result.next) score = result.next;
1158
+ messages.push(result.message);
1159
+ }
1160
+ const next = score.tracks.find((t) => t.id === trackId)!;
1161
+ return {
1162
+ kind: "score",
1163
+ operations: [
1164
+ {
1165
+ type: "updateTrack",
1166
+ trackId,
1167
+ patch: { instrument: next.instrument, wind: next.wind ?? null },
1168
+ },
1169
+ ],
1170
+ trackId,
1171
+ summary: `${trackId} ${messages.at(-1)}`,
1172
+ };
1173
+ },
1174
+ },
1036
1175
  {
1037
1176
  name: "set_sample",
1038
1177
  description:
1039
- "Set a sampler voice's Strudel sample controls: begin end gain speed unit(r|c|s) loop loopBegin loopEnd clip(legato) fit loopAt accelerate squiz cut; null unsets one.",
1178
+ "Set a sampler voice's Strudel sample controls: begin end gain speed unit(r|c|s) loop loopBegin loopEnd clip(legato) fit loopAt accelerate squiz cut, and (0.6.1) shift (semitones, length kept) formant (0 keeps the voice's formants) fadeInTime fadeTime (seconds), vel [lo,hi] (velocity layer) rr (round-robin group). null unsets one.",
1040
1179
  parameters: {
1041
1180
  type: "object",
1042
1181
  properties: {
@@ -1045,7 +1184,15 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
1045
1184
  params: {
1046
1185
  type: "object",
1047
1186
  additionalProperties: {
1048
- type: ["number", "string", "boolean", "null"],
1187
+ anyOf: [
1188
+ { type: ["number", "string", "boolean", "null"] },
1189
+ {
1190
+ type: "array",
1191
+ items: { type: "integer", minimum: 0, maximum: 127 },
1192
+ minItems: 2,
1193
+ maxItems: 2,
1194
+ },
1195
+ ],
1049
1196
  },
1050
1197
  },
1051
1198
  },
@@ -1505,7 +1652,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
1505
1652
  typeof args.instrument === "string"
1506
1653
  ? args.instrument.toLowerCase()
1507
1654
  : "";
1508
- const preset = word === "piano" ? "grand" : word;
1655
+ const preset = pianoWrite(word)?.preset ?? word;
1509
1656
  if (isKeysPreset(preset)) {
1510
1657
  const patch = keysPresetPatch({}, preset);
1511
1658
  return {
@@ -1822,6 +1969,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
1822
1969
  ...PACK_TOOLS,
1823
1970
  ...MASTER_TOOLS,
1824
1971
  ...GRANULAR_TOOLS,
1972
+ ...RESAMPLE_TOOLS,
1825
1973
  // Looks tools up at call time, so it can plan any of the above.
1826
1974
  previewSoundTool((name) => findAgentTool(name)),
1827
1975
  ] satisfies AgentTool[]);
@@ -1969,19 +2117,54 @@ function fxToolCommand(
1969
2117
  return { type: "fx-set", effect, values };
1970
2118
  }
1971
2119
 
2120
+ /** f061-organ: set_keys drawbars/registers/stops/rotary as keys values. */
2121
+ function organToolValues(
2122
+ args: Record<string, unknown>,
2123
+ ): Record<string, string> {
2124
+ const out: Record<string, string> = {};
2125
+ for (const name of ["drawbars", "registers", "rotary"] as const) {
2126
+ const value = args[name];
2127
+ if (value === undefined) continue;
2128
+ if (typeof value !== "string")
2129
+ throw new ToolArgumentError(`keys ${name} must be a string`);
2130
+ out[name] = value;
2131
+ }
2132
+ if (args.stops !== undefined) {
2133
+ const words = Array.isArray(args.stops)
2134
+ ? args.stops
2135
+ : typeof args.stops === "string"
2136
+ ? args.stops.split(/[\s,+]+/)
2137
+ : undefined;
2138
+ if (!words || !words.every((word) => typeof word === "string"))
2139
+ throw new ToolArgumentError("keys stops must be stop names");
2140
+ out.stops = words.join(" ");
2141
+ }
2142
+ return out;
2143
+ }
2144
+
1972
2145
  function keysToolCommand(args: Record<string, unknown>): KeysCommand {
1973
2146
  if (args.reset === true) return { type: "keys-reset" };
2147
+ const organ = organToolValues(args);
2148
+ const organSet = Object.keys(organ).length > 0;
1974
2149
  if (args.preset !== undefined) {
1975
- if (typeof args.preset !== "string" || !isKeysPreset(args.preset))
2150
+ const preset =
2151
+ typeof args.preset === "string"
2152
+ ? (pianoWrite(args.preset)?.preset ?? args.preset)
2153
+ : undefined;
2154
+ if (preset === undefined || !isKeysPreset(preset))
1976
2155
  throw new ToolArgumentError(
1977
2156
  `keys presets: ${Object.keys(KEYS_PRESETS).join(", ")}`,
1978
2157
  );
1979
- return { type: "keys-preset", preset: args.preset };
2158
+ return organSet
2159
+ ? { type: "keys-preset", preset, values: organ }
2160
+ : { type: "keys-preset", preset };
1980
2161
  }
2162
+ if (args.params === undefined && organSet)
2163
+ return { type: "keys-set", values: organ };
1981
2164
  if (args.params === undefined)
1982
2165
  throw new ToolArgumentError("set_keys needs preset, reset, or params");
1983
2166
  const params = record(args.params, "params");
1984
- const values: Record<string, number | string | null> = {};
2167
+ const values: Record<string, number | string | null> = { ...organ };
1985
2168
  for (const [name, value] of Object.entries(params)) {
1986
2169
  const param = keysParamName(name);
1987
2170
  if (!param)
@@ -2112,6 +2295,7 @@ function instrumentName(value: unknown): {
2112
2295
  instrument: string;
2113
2296
  string?: { preset: string };
2114
2297
  modal?: TrackModal;
2298
+ wind?: TrackWind;
2115
2299
  } {
2116
2300
  const word = typeof value === "string" ? value.trim() : undefined;
2117
2301
  if (word === STRING_INSTRUMENT)
@@ -2124,6 +2308,7 @@ function instrumentName(value: unknown): {
2124
2308
  patch === undefined ||
2125
2309
  (!patch.string &&
2126
2310
  !patch.modal &&
2311
+ !patch.wind &&
2127
2312
  !(AVAILABLE_INSTRUMENTS as readonly string[]).includes(patch.instrument))
2128
2313
  )
2129
2314
  throw new ToolArgumentError(
@@ -51,15 +51,21 @@ export const TEXT_AGENT_SYSTEM_PROMPT = [
51
51
  CHORD_PROCESS,
52
52
  "Synth voices: set_synth shapes a synth track with Strudel params (ADSR, lpf/lpenv filter envelopes, fm/fmh, supersaw unison/detune, vib, penv, noise; z_* ZzFX sounds take slide/pitchJump/lfo/zcrush…) or a preset (pad lead pluck bass keys bell…).",
53
53
  "Plucked strings: set_string makes a track a physical-model string (nylon steel electric jangle ebass slap upright motown sitar tanpura harpsichord lute oud setar tar santur dulcimer koto harp banjo tres requinto) and overrides params (ring bright damp pos mute buzz body sym). The bare words sitar, ebass, pluck keep their older voices; use set_string for the modelled ones.",
54
- "Pianos: set_instrument piano (or grand upright felt honkytonk prepared, ballad, lofi) loads a modelled piano with real hammers, stretched inharmonic strings, dampers and the sustain pedal; set_keys tweaks it (hardness touch decay release felt stretch body…, automate as keys-<param> lanes). A track already stored as instrument piano keeps the legacy tone until you set it again.",
54
+ "Bowed strings: set_string preset violin|viola|cello|contrabass|fiddle|erhu|kamancheh|violins|violas|cellos|contrabasses|pizz|trem plays a bowed physical model (bow params pressure speed attack vib vibmod vibdelay tremhz sord dyn). Overlapping or legato notes slur in one bow. The bare words cello, contrabass and strings keep their older voices; use set_string (or the words violin, viola, fiddle, erhu, violins, cellos) for the bowed ones. Sampler voices take vel [lo, hi] velocity layers and rr round-robin groups via set_sample.",
55
+ "Pianos: set_instrument piano (or grand upright felt honkytonk prepared, ballad, lofi) loads a modelled piano with real hammers, stretched inharmonic strings, dampers and the sustain pedal; set_keys tweaks it (hardness touch decay release felt stretch body…, automate as keys-<param> lanes; sym 0.5 adds sympathetic bloom under the sustain pedal for pedalled Romantic and Impressionist playing). A track already stored as instrument piano keeps the legacy tone until you set it again.",
56
+ "Electric keys: set_instrument epiano (rhodes; suitcase stereo vibe, dyno bright 80s), wurli, clav (funkclav); set_keys tweaks bark bell tone vibe (epiano), trem (wurli), pickup neck|bridge|both|out and mute (clav). Soft and sostenuto pedals: set_piano_pedals on any modelled piano.",
57
+ 'Organs: set_instrument tonewheel (hammond b3 gospel jazzorgan), combo (farfisa vox) or pipe (church flutes cornet reeds celeste) loads a modelled organ; instrument organ stays the legacy sine. set_keys drawbars "888800008" (16\' to 1\'), registers "08880" (combo), stops ["principal8","octave4"] or plenum|flutes|cornet|reeds|strings|full (pipe), rotary slow|fast|stop, params perc 3rd percdecay percvol click scanner c3 drive chiff wind trem (each family takes only its own rows); automate keys-rotary and keys-drive lanes.',
55
58
  "Mallets and bells: set_instrument vibes (or marimba via modal, xylophone glock celesta chimes kalimba mbira steelpan bowl gong timpani) plays the modal engine; set_modal changes mallet (yarn…brass), hardness, position, ring, damp, motor/motordepth (vibes tremolo), buzz, click. Plain instrument marimba stays the legacy tone.",
59
+ "Gamelan: set_modal preset saron demung slenthem gangsa gender bonang kenong kethuk kempul (gong too) with tuning slendro or pelog; for ombak (the shimmer) put the same part on two gangsa tracks and set_modal pair <other track> on one: it sounds ombak Hz (default 6) above its partner. Frame drums: daf bodhran tabla.",
60
+ "Winds and brass: set_instrument flute recorder whistle ney shakuhachi panpipe suling bansuri clarinet bassclarinet oboe bassoon sax altosax barisax trumpet harmon plunger trombone tuba horn plays the wind engine; set_wind changes breath, bright, mute (open straight cup harmon plunger), players (2..8 spreads a section over chord tones), growl, vib. Lines slur unless re-tongued (gaps or articulations). Plain instrument wind stays the legacy tone.",
56
61
  "Sampler voices: set_sample sets Strudel sample controls per voice (begin end speed unit loop loopBegin loopEnd clip/legato fit loopAt accelerate squiz cut); splice = slices with fit.",
57
62
  "Fitting samples (0.6): fit_sample sets a voice's own bpm (or len in beats) so it follows the song's tempo map; fitmode beats for drums and speech (hits stay sharp), tones for pads and vocals (pitch kept), repitch (default) like tape.",
58
63
  "Effects (set_fx; set_effects and set_automation): the brief's effects list is the fixed chain order with each effect's simple params. set_fx turns an effect on with good defaults, loads a preset or sets params by dawg or Strudel name. Lanes: volume, pan, filter, resonance, delay-feedback, delay-mix, wt and <effect>-<param> (e.g. autofilter-cutoff).",
59
64
  "Guitar rig (set_rig): rig loads a whole rig (clean crunch punk ragged lead metal fuzz octave funk wah bachata spring bassdrive reese jangle alt) on a guitar or bass track; stomp, head and cab tweak the pedal, amp (level-matched, gate in dB) and cabinet. Tracks named jangle punk funk ragged gtr-lead gtr-metal bachata start with a guitar voice and that rig. `amp` is Strudel gain; the guitar amp is head.",
65
+ "Shoegaze: rig shoegaze|glide|dreampop|swell|ebow (or a track named shoegaze); set_fx wobble (tremolo-arm bend: depth cents, rate, drift), bloom (feedback swell on the top note), swell (volume swell: time), double (second take: time, drift, width); reverb ir builtin:reverse|gate|spring. Strumming: set_guitar (tune dadgad, capo, hand, ring), strum_chords (chords or the track's own, strokes folk|pop|punk… or D-DU-UDU, speed ms), write_chords perform guitar.",
60
66
  "Previewing: preview_sound renders a track, or candidate sound tool calls (changes: [{tool, args}]), without committing, returns loudness, brightness and a comparison, and plays it once in the user's window. Use it when choosing between sounds (tables, presets, effect amounts), say how it sounds, then commit with the normal tools.",
61
67
  "Wavetable synth: set_wavetable picks a table (built-ins offline, Strudel wt_ sets fetched once) and scans it by position wt.",
62
- `Granular: set_granular makes a track a grain cloud (presets cloud hold sparkle swarm stutter microloop backwards dust) of a synth source (src synth:pad, the default; synth:<name>@<note>) or one of its own sampler voices, and overrides params (${Object.keys(GRANULAR_PARAMS).join(" ")}; repeat and hold latch the head for beat repeats, begin and end pick a region, root takes a MIDI number or note name); off returns to the previous voice; the same seed gives the same grains.`,
68
+ `Granular: set_granular makes a track a grain cloud (presets cloud hold sparkle swarm stutter microloop backwards dust) of a synth source (src synth:pad, the default; synth:<name>@<note>) or one of its own sampler voices, and overrides params (${Object.keys(GRANULAR_PARAMS).join(" ")}; repeat and hold latch the head for beat repeats, begin and end pick a region, root takes a MIDI number or note name); off returns to the previous voice; the same seed gives the same grains. Grain play: sync "1/16" puts grain onsets on the tempo grid, quant scale|chord snaps grain pitches, mono true glides legato notes through one cloud, pedal true freezes the head while the sustain pedal is down; set_automation grain-<param> lanes (grain-pos, grain-scan, grain-pitch …) move a parameter over beats. Resample: the resample tool renders a track, orbit or the mix (a section or bars) to a pinned WAV and adds a sampler track, or with grain a granular track, that plays it; set_sample shift (semitones, length kept, formant 0 keeps a voice's character) and fadeInTime/fadeTime shape a sampler voice.`,
63
69
  "Mastering: set_master sets the song master and loudness target; measure_mix reports LUFS, true peak, balance and correlation. Master only when asked for loudness or a finished sound, and measure before and after.",
64
70
  'Reply with exactly one JSON object and nothing else, shaped {"ops":[{"tool":"<tool name>","args":{...}}],"say":"<one short sentence describing the musical change>","done":true}.',
65
71
  'Set "done":false only if you need to see the results of these ops before continuing; you will then get each op\'s result and can send more ops.',
@@ -51,6 +51,7 @@ import {
51
51
  type RenderedAudio,
52
52
  type WavCue,
53
53
  } from "./wav.ts";
54
+ import { engineFor } from "./instruments.ts";
54
55
 
55
56
  /** What one default render pass holds before the renderer cuts it. */
56
57
  const DEFAULT_PASS_SECONDS = 30;
@@ -393,11 +394,31 @@ function renderWindows(
393
394
  notes,
394
395
  master: null,
395
396
  });
397
+ // State an engine cannot rebuild in the pre-roll (organ rotors along a
398
+ // lane) is integrated over the song before the window.
399
+ const seedState: Record<string, string> = {};
400
+ if (
401
+ pre > 0 &&
402
+ window.tracks.some((track) => engineFor(track)?.windowSeed)
403
+ ) {
404
+ const history = frame(0, pre);
405
+ for (const track of history.tracks) {
406
+ const seed = engineFor(track)?.windowSeed?.(
407
+ history,
408
+ track,
409
+ frameAt(pre),
410
+ sampleRate,
411
+ );
412
+ if (seed !== undefined) seedState[track.id] = seed;
413
+ }
414
+ }
396
415
  const audio = renderer.render(window, {
397
416
  ...options,
398
417
  sampleRate,
399
418
  loop: false,
400
419
  seedTick: preTick,
420
+ ...(pre > 0 ? { seedSeconds: secondsAtTick(timeline, pre * ticks) } : {}),
421
+ ...(Object.keys(seedState).length > 0 ? { seedState } : {}),
401
422
  // Past `end` too, so voices that fade where the buffer stops (the
402
423
  // legacy tone voice) fade in frames this window drops.
403
424
  maxSeconds: last
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Cepstral spectral envelope (0.6 B2): the smooth outline of a magnitude
3
+ * spectrum, used by `shift.ts` to hold formants still while the pitch moves.
4
+ * Log magnitude, inverse transform to the real cepstrum, keep the
5
+ * quefrencies below the lifter, forward transform, exponentiate
6
+ * (Oppenheim and Schafer, *Discrete-Time Signal Processing*, ch. 13;
7
+ * J. O. Smith, *Spectral Audio Signal Processing*, "Cepstral Windowing";
8
+ * Röbel and Rodet, "Efficient spectral envelope estimation and its
9
+ * application to pitch shifting and envelope preservation", DAFx 2005, for
10
+ * the true-envelope iteration).
11
+ */
12
+ import { fftInPlace } from "./fft.ts";
13
+ import { inverse } from "./stft.ts";
14
+
15
+ /**
16
+ * Default lifter: quefrencies below 2 ms (measured on a synthetic vowel:
17
+ * with 16 true-envelope passes F1 stays within 3% after +-7 and +-12 st at
18
+ * 110 Hz; 1 ms smeared F1 by up to 15%).
19
+ */
20
+ export const ENVELOPE_LIFTER_SECONDS = 0.002;
21
+ /** True-envelope passes used by `pitchShift` when keeping formants. */
22
+ export const ENVELOPE_ITERATIONS = 16;
23
+
24
+ /** Adaptive lifter as a fraction of the cepstral period. */
25
+ export const PERIOD_FRACTION = 0.25;
26
+
27
+ /** Scratch buffers for one frame size, reused across frames. */
28
+ export type EnvelopeScratch = Readonly<{
29
+ re: Float64Array;
30
+ im: Float64Array;
31
+ log: Float64Array;
32
+ }>;
33
+
34
+ export function envelopeScratch(n: number): EnvelopeScratch {
35
+ return Object.freeze({
36
+ re: new Float64Array(n),
37
+ im: new Float64Array(n),
38
+ log: new Float64Array(n / 2 + 1),
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Writes the cepstral envelope of `mag` (bins 0..n/2 of an n-point
44
+ * spectrum) into `out` (length n/2 + 1). `lifter` is in samples (at least
45
+ * 4). With `iterations` > 0 it is the true envelope (Röbel and Rodet 2005):
46
+ * the log spectrum is raised to the smoothed curve wherever it lies below
47
+ * it and smoothed again, so the envelope rides the harmonic peaks instead
48
+ * of the valleys between them. With `periodRange` (samples) the lifter
49
+ * shrinks to a quarter of the frame's cepstral pitch period when that is
50
+ * shorter, so higher voices keep their harmonics out of the envelope.
51
+ */
52
+ export function cepstralEnvelope(
53
+ mag: Float64Array,
54
+ n: number,
55
+ lifter: number,
56
+ out: Float64Array,
57
+ scratch: EnvelopeScratch = envelopeScratch(n),
58
+ iterations = 0,
59
+ periodRange?: readonly [number, number],
60
+ ): Float64Array {
61
+ const half = n / 2;
62
+ const { re, im, log } = scratch;
63
+ let peak = 0;
64
+ for (let k = 0; k <= half; k += 1) peak = Math.max(peak, mag[k]!);
65
+ // Floor 120 dB under the frame's peak (silence stays finite).
66
+ const floor = peak * 1e-6 + 1e-12;
67
+ for (let k = 0; k <= half; k += 1) log[k] = Math.log(mag[k]! + floor);
68
+ let cut = Math.max(4, Math.min(half, Math.round(lifter)));
69
+ for (let pass = 0; pass <= iterations; pass += 1) {
70
+ for (let k = 0; k < n; k += 1) {
71
+ re[k] = log[k <= half ? k : n - k]!;
72
+ im[k] = 0;
73
+ }
74
+ inverse(re, im);
75
+ if (pass === 0 && periodRange) {
76
+ // Pitch-adaptive lifter: a quarter of the period found as the
77
+ // strongest cepstral peak in range, never longer than `lifter`.
78
+ const [lo, hi] = periodRange;
79
+ let q = Math.max(2, Math.round(lo));
80
+ for (let i = q; i <= Math.min(half, Math.round(hi)); i += 1)
81
+ if (re[i]! > re[q]!) q = i;
82
+ cut = Math.max(4, Math.min(cut, Math.round(q * PERIOD_FRACTION)));
83
+ }
84
+ for (let q = cut; q <= n - cut; q += 1) {
85
+ re[q] = 0;
86
+ im[q] = 0;
87
+ }
88
+ fftInPlace(re, im);
89
+ if (pass < iterations)
90
+ for (let k = 0; k <= half; k += 1) log[k] = Math.max(log[k]!, re[k]!);
91
+ }
92
+ for (let k = 0; k <= half; k += 1) out[k] = Math.exp(re[k]!);
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * Harmonic-peak envelope (0.6.1): log magnitude interpolated linearly
98
+ * between the spectral peaks (each refined by a parabola to its true bin
99
+ * and height) of `mag` that stand within `range` of the
100
+ * frame's loudest, held flat past the first and last. Between harmonics it
101
+ * follows the line joining them instead of the smoothed valley, so it stays
102
+ * on the formant at any pitch where the cepstral envelope (whose lifter
103
+ * must shrink with the period) starts to smear or ripple. `smooth` (an
104
+ * envelope from `cepstralEnvelope`) is used where no peak stands.
105
+ * Measured with `formant 0` on a synthetic vowel: F1 within 1.1% of the
106
+ * same vowel sung at the new pitch for f0 110-330 Hz at +-7 and +12 st
107
+ * (the cepstral envelope alone: -29% to +46% above 200 Hz).
108
+ */
109
+ export function peakEnvelope(
110
+ mag: Float64Array,
111
+ half: number,
112
+ out: Float64Array,
113
+ smooth: Float64Array,
114
+ peaks: number[],
115
+ range = 1e-4,
116
+ ): Float64Array {
117
+ let top = 0;
118
+ for (let k = 0; k <= half; k += 1) top = Math.max(top, mag[k]!);
119
+ peaks.length = 0;
120
+ const floor = top * range;
121
+ for (let k = 2; k <= half - 2; k += 1) {
122
+ const v = mag[k]!;
123
+ if (
124
+ v > floor &&
125
+ v > mag[k - 1]! &&
126
+ v >= mag[k + 1]! &&
127
+ v > mag[k - 2]! &&
128
+ v >= mag[k + 2]!
129
+ )
130
+ peaks.push(k);
131
+ }
132
+ if (peaks.length < 2) {
133
+ out.set(smooth.subarray(0, half + 1));
134
+ return out;
135
+ }
136
+ // Each peak refined by a parabola on log magnitude: its true frequency
137
+ // (fractional bin) and height.
138
+ const at = (i: number) => {
139
+ const k = peaks[i]!;
140
+ const a = Math.log(mag[k - 1]! + 1e-30);
141
+ const b = Math.log(mag[k]! + 1e-30);
142
+ const c = Math.log(mag[k + 1]! + 1e-30);
143
+ const d = a - 2 * b + c;
144
+ const delta =
145
+ d < 0 ? Math.max(-0.5, Math.min(0.5, (0.5 * (a - c)) / d)) : 0;
146
+ return [k + delta, b - 0.25 * (a - c) * delta] as const;
147
+ };
148
+ let j = 0;
149
+ let [x0, y0] = at(0);
150
+ let [x1, y1] = at(1);
151
+ for (let k = 0; k <= half; k += 1) {
152
+ while (j + 2 < peaks.length && x1 <= k) {
153
+ j += 1;
154
+ [x0, y0] = [x1, y1];
155
+ [x1, y1] = at(j + 1);
156
+ }
157
+ const t = Math.max(0, Math.min(1, (k - x0) / (x1 - x0)));
158
+ out[k] = Math.exp(y0 + (y1 - y0) * t);
159
+ }
160
+ return out;
161
+ }