@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
@@ -12,13 +12,16 @@ Targets are `all` (default), `bar 3`, `bars 2-4` or note ids.
12
12
 
13
13
  - "make the bass slide like a 303" · "ghost the off-beat hats"
14
14
  - "pedal the piano every bar" · "humanize the drums a little"
15
+ - "play the piano una corda" · "hold the bass note with sostenuto"
15
16
 
16
17
  ## Type it yourself
17
18
 
18
19
  - `art staccato|legato|accent|tenuto|marcato|ghost [target]`
19
20
  - `glide 60ms legato` per track · `glide 60ms bar 2` per note
21
+ - bowed strings slur overlapping or `art legato` notes in one bow stroke
20
22
  - `bend scoop|fall|doit|-200 [target]` · `vibrato 5.5 30 0.2`
21
23
  - `pedal 0-3.5 4-7.5` (beats) · `pedal bars` · `pedal off`
24
+ - `pedal soft 0-8` una corda · `pedal sost 0-4` holds keys down at 0
22
25
  - `velcurve soft|hard|fixed 0.7` · `humanize 8 5 10 seed 3`
23
26
 
24
27
  ## Menu
@@ -0,0 +1,26 @@
1
+ ---
2
+ id: resample
3
+ title: Resample, grains and shift
4
+ parent: sound
5
+ order: 8
6
+ ---
7
+
8
+ Turn a part into a sample, grain it, and play the grains like an instrument.
9
+
10
+ ## Ask
11
+
12
+ - "resample the lead and make a grain cloud of bars 1-2"
13
+ - "shift the vocal sample up a fifth but keep its character"
14
+
15
+ ## Type it yourself
16
+
17
+ - `resample lead` · `resample drums bars 1-2 grain` · `resample master section chorus`
18
+ - `shift 7` same length · `shift 7 formant keep` · `fade out 0.5` · `fade in 0.05`
19
+ - `grain sync 1/16` · `grain quant scale` · `grain mono on` · `grain pedal on`
20
+ - `automate grain-pos points 0:0.1 8:0.9` sweeps the head
21
+
22
+ ## Menu
23
+
24
+ - Ctrl-K › Project › Resample: a track, an orbit or the mix
25
+ - Ctrl-K › Sound › granular: Sync, Quant, Mono, Pedal
26
+ - Ctrl-K › Sound › Sample: Shift, Formant, Fade in, Fade out
package/guides/sounds.md CHANGED
@@ -14,12 +14,17 @@ order: 1
14
14
 
15
15
  - `instrument sawtooth` · sine square triangle supersaw pulse
16
16
  - `synth preset pluck` · pad lead bass acid keys bell organ strings
17
- - `synth lpf 800 lpenv 3` any parameter · `synth adsr 0.01 0.2 0.5 0.3`
18
- - `synth lpf off` · `synth reset` · `synth` lists what is set
17
+ - `synth lpf 800 lpenv 3` · `synth adsr 0.01 0.2 0.5 0.3` · `synth reset`
18
+ - `piano felt` · `epiano suitcase` · `wurli` · `clav` · `keys hardness 0.3`
19
+ - `tonewheel` gospel · `combo` vox · `pipe` flutes · `rotary fast` · `keys`
19
20
  - `wt list` tables · `wt pwm` load one · `wt 0.5` position
21
+ - `string sitar` · `bowed violin` · cellos erhu koto … · `string presets`
22
+ - `wind sax` · flute trumpet … · `wind breath 0.8` · `modal gamelan` bronzes
20
23
  - `/pack list` · `/pack use <pack>/<sound> as kick` · `/pack info vcsl`
21
24
  - `/sample samples/kick.wav as kick` add a sampler voice
25
+ - `/bpm 174` sample tempo (`bpm` is the song's) · `fitmode beats` · `len 8`
22
26
 
23
27
  ## Menu
24
28
 
25
29
  - Ctrl-K › Sound: instrument, preset, envelope, filter, browse sounds
30
+ - Ctrl-K › Sound › browse sounds › Strings: plucked presets, Bowed
package/guides/tempo.md CHANGED
@@ -20,6 +20,7 @@ Positions are beats from 0 or `bar <n>`.
20
20
  - `fermata at 31 2` hold beat 31 for 2 extra beats
21
21
  - `meter 7/8 at bar 5` · `tempo map` show it · `tempo clear`
22
22
  - `track rate 3/2` · `track cycle 3` · `track phasing 3` · `track time off`
23
+ - `/bpm 174` on a sampler voice follows the map · `fitmode beats|tones` keeps pitch
23
24
 
24
25
  ## Menu
25
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/dawg",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,3 +1,4 @@
1
+ import { GRANULAR_PARAMS } from "../../core/granular.ts";
1
2
  import {
2
3
  applyScoreOperation,
3
4
  type ScoreOperation,
@@ -201,10 +202,22 @@ export const AGENT_SYSTEM_PROMPT = [
201
202
  "Song structure: list_sections shows named sections (bars count from 1); edit_section marks, adds, duplicates, moves, renames, deletes, mutes, varies (transpose/gain) and loops them; set_form sets the play order with repeats (intro verse chorus*2 outro); add_transition generates builds (riser, snare roll, filter sweep, uplifter), drops (cut plus impact) and drum fills at section boundaries.",
202
203
  CHORD_PROCESS,
203
204
  "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…).",
205
+ "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.",
206
+ "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.",
207
+ "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.",
208
+ "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.",
209
+ '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.',
210
+ "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.",
211
+ "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.",
212
+ "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.",
204
213
  "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.",
214
+ "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.",
205
215
  "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). Sidechain: put pads/bass on `orbit` 2 and give the kick `duck` (orbit 2, preset pump); a ducker never ducks itself.",
216
+ "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.",
217
+ "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.",
206
218
  "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.",
207
219
  "Wavetable synth: set_wavetable picks a table (built-ins offline, Strudel wt_ sets fetched once) and scans it by position wt.",
220
+ `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.`,
208
221
  "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.",
209
222
  "If a call is rejected, read the diagnostic and either fix the arguments or stop.",
210
223
  WORKSPACE_PROMPT,
@@ -12,8 +12,12 @@ import {
12
12
  generateProgression,
13
13
  keyName,
14
14
  parseKey,
15
+ DEFAULT_STROKE_SPEED,
16
+ GUITAR_TUNING_NAMES,
15
17
  PERFORM_MODES,
16
18
  PROGRESSION_PRESETS,
19
+ STROKE_PATTERN_NAMES,
20
+ strokeGrid,
17
21
  PROGRESSION_STYLES,
18
22
  renderProgression,
19
23
  resolveChord,
@@ -27,6 +31,12 @@ import { midiToPitch } from "../../core/pitch.ts";
27
31
  import { resolveTuning, snapToTuning } from "../../core/tuning.ts";
28
32
  import { SCORE_LIMITS, type ScoreOperation } from "../../core/score.ts";
29
33
  import type { AgentTool, ToolContext } from "./tools.ts";
34
+ import {
35
+ applyGuitarCommand,
36
+ parseGuitarCommand,
37
+ planStrum,
38
+ StrumError,
39
+ } from "../commands/strum.ts";
30
40
 
31
41
  /** Most notes one write_chords call may add (arpeggios multiply fast). */
32
42
  export const MAX_CHORD_NOTES = 512;
@@ -193,7 +203,9 @@ export const CHORD_TOOLS: readonly AgentTool[] = Object.freeze([
193
203
  perform: { type: "string", enum: [...PERFORM_MODES] },
194
204
  rate: { ...num, description: "arp step, beats" },
195
205
  octaves: int,
196
- strum: num,
206
+ strum: { ...num, description: "strum/strum-up gap, beats" },
207
+ strokes: { ...str, description: "perform guitar: see strum_chords" },
208
+ speed: { ...num, description: "perform guitar: ms per stroke" },
197
209
  velocity: num,
198
210
  pattern: { type: "string", enum: CHORD_PATTERNS.map((p) => p.name) },
199
211
  bass: bool,
@@ -225,6 +237,15 @@ export const CHORD_TOOLS: readonly AgentTool[] = Object.freeze([
225
237
  const velocity = finite(args, "velocity", 0, 1);
226
238
  const rate = finite(args, "rate", 0.0625, 4);
227
239
  const strum = finite(args, "strum", 0, 1);
240
+ const speedMs = finite(args, "speed", 0, 200);
241
+ const strokes = args.strokes;
242
+ if (
243
+ strokes !== undefined &&
244
+ (typeof strokes !== "string" || !strokeGrid(strokes))
245
+ )
246
+ throw new ChordToolError(
247
+ `strokes must be ${STROKE_PATTERN_NAMES.join(", ")} or a grid of D U d u x - .`,
248
+ );
228
249
  const octaves = integer(args, "octaves", 1, 4);
229
250
  const pattern = args.pattern;
230
251
  if (
@@ -259,6 +280,16 @@ export const CHORD_TOOLS: readonly AgentTool[] = Object.freeze([
259
280
  ...(velocity !== undefined ? { velocity } : {}),
260
281
  seed: integer(args, "seed", 0, 2 ** 31) ?? 0,
261
282
  ...(typeof pattern === "string" ? { pattern } : {}),
283
+ ...(mode === "guitar"
284
+ ? {
285
+ ...(typeof strokes === "string" ? { strokes } : {}),
286
+ speed: (speedMs ?? DEFAULT_STROKE_SPEED * 1000) / 1000,
287
+ tempo: context.score.tempoBpm,
288
+ ...(guitarOf(context, trackId)
289
+ ? { guitar: guitarOf(context, trackId) }
290
+ : {}),
291
+ }
292
+ : {}),
262
293
  },
263
294
  });
264
295
  const total = rendered.notes.length + rendered.bass.length;
@@ -308,8 +339,129 @@ export const CHORD_TOOLS: readonly AgentTool[] = Object.freeze([
308
339
  };
309
340
  },
310
341
  },
342
+ {
343
+ name: "strum_chords",
344
+ description: `Strum chords as fretted guitar notes (set_guitar). chords omitted strums the track's block chords. strokes ${STROKE_PATTERN_NAMES.join("|")} or a DUdux-. grid; speed ms/stroke (22); step grid beats; each beats/chord (a bar).`,
345
+ parameters: {
346
+ type: "object",
347
+ properties: {
348
+ trackId: str,
349
+ chords: chordsSchema,
350
+ strokes: str,
351
+ speed: num,
352
+ step: num,
353
+ each: num,
354
+ at: num,
355
+ velocity: num,
356
+ seed: int,
357
+ },
358
+ additionalProperties: false,
359
+ },
360
+ plan(args, context) {
361
+ const trackId = trackOf(args.trackId, context, "trackId");
362
+ const chords = args.chords;
363
+ if (
364
+ chords !== undefined &&
365
+ (!Array.isArray(chords) ||
366
+ chords.length > MAX_CHORDS ||
367
+ !chords.every((c) => typeof c === "string"))
368
+ )
369
+ throw new ChordToolError(`chords must be up to ${MAX_CHORDS} strings`);
370
+ if (args.strokes !== undefined && typeof args.strokes !== "string")
371
+ throw new ChordToolError("strokes must be a string");
372
+ const speedMs = finite(args, "speed", 0, 200);
373
+ const step = finite(args, "step", 1 / 32, 4);
374
+ const each = finite(args, "each", 1e-3, 64);
375
+ const at = finite(args, "at", 0, Infinity);
376
+ const velocity = finite(args, "velocity", 0, 1);
377
+ const seed = integer(args, "seed", 0, 2 ** 31);
378
+ try {
379
+ const plan = planStrum(
380
+ context.score,
381
+ trackId,
382
+ {
383
+ chords: (chords as string[] | undefined) ?? [],
384
+ ...(typeof args.strokes === "string"
385
+ ? { strokes: args.strokes }
386
+ : {}),
387
+ ...(speedMs !== undefined ? { speedMs } : {}),
388
+ ...(step !== undefined ? { step } : {}),
389
+ ...(each !== undefined ? { each } : {}),
390
+ ...(at !== undefined ? { at } : {}),
391
+ ...(velocity !== undefined ? { velocity } : {}),
392
+ ...(seed !== undefined ? { seed } : {}),
393
+ },
394
+ (index) => context.newNoteId(trackId, index),
395
+ MAX_CHORD_NOTES,
396
+ );
397
+ return {
398
+ kind: "score",
399
+ operations: plan.operations,
400
+ trackId,
401
+ summary: `${trackId} strum ${plan.names.join(" ")}`.slice(0, 160),
402
+ };
403
+ } catch (error) {
404
+ if (error instanceof StrumError)
405
+ throw new ChordToolError(error.message);
406
+ throw error;
407
+ }
408
+ },
409
+ },
410
+ {
411
+ name: "set_guitar",
412
+ description: `Guitar fretting for strum: tune ${GUITAR_TUNING_NAMES.join("|")} or notes low-high ("D A D G A D"), capo 0..12, hand 3..6 frets, ring 0..1, position 0..12, reset.`,
413
+ parameters: {
414
+ type: "object",
415
+ properties: {
416
+ trackId: str,
417
+ tune: str,
418
+ capo: int,
419
+ hand: int,
420
+ ring: num,
421
+ position: int,
422
+ reset: bool,
423
+ },
424
+ additionalProperties: false,
425
+ },
426
+ plan(args, context) {
427
+ const trackId = trackOf(args.trackId, context, "trackId");
428
+ let score = context.score;
429
+ const messages: string[] = [];
430
+ const run = (command: string) => {
431
+ const parsed = parseGuitarCommand(command);
432
+ const result = parsed
433
+ ? applyGuitarCommand(score, trackId, parsed)
434
+ : { ok: false, message: `cannot parse ${command}` };
435
+ if (!result.ok) throw new ChordToolError(result.message);
436
+ if ("next" in result && result.next) score = result.next;
437
+ messages.push(result.message);
438
+ };
439
+ if (args.reset === true) run("guitar reset");
440
+ if (args.tune !== undefined) run(`guitar tune ${String(args.tune)}`);
441
+ for (const field of ["capo", "hand", "ring", "position"] as const)
442
+ if (args[field] !== undefined)
443
+ run(`guitar ${field} ${String(args[field])}`);
444
+ if (messages.length === 0)
445
+ throw new ChordToolError(
446
+ "give tune, capo, hand, ring, position or reset",
447
+ );
448
+ const guitar = score.tracks.find((t) => t.id === trackId)?.guitar;
449
+ return {
450
+ kind: "score",
451
+ operations: [
452
+ { type: "updateTrack", trackId, patch: { guitar: guitar ?? null } },
453
+ ],
454
+ trackId,
455
+ summary: messages.at(-1)!,
456
+ };
457
+ },
458
+ },
311
459
  ]);
312
460
 
461
+ function guitarOf(context: ToolContext, trackId: string) {
462
+ return context.score.tracks.find((track) => track.id === trackId)?.guitar;
463
+ }
464
+
313
465
  function trackOf(value: unknown, context: ToolContext, label: string): string {
314
466
  const trackId = value ?? context.focusedTrackId;
315
467
  if (typeof trackId !== "string" || trackId.length > SCORE_LIMITS.maxIdLength)
@@ -9,6 +9,7 @@ import {
9
9
  ExpressionValidationError,
10
10
  GLIDE_MODES,
11
11
  PEDAL_STATES,
12
+ SOSTENUTO_STATES,
12
13
  VELOCITY_CURVES,
13
14
  normalizeArticulation,
14
15
  normalizeBend,
@@ -20,12 +21,14 @@ import {
20
21
  normalizeVibrato,
21
22
  normalizeNoteHumanize,
22
23
  type NoteExpressionPatch,
24
+ type PedalState,
23
25
  } from "../../core/expression.ts";
24
26
  import {
25
27
  SCORE_LIMITS,
26
28
  type ScoreOperation,
27
29
  type TrackPatch,
28
30
  } from "../../core/score.ts";
31
+ import { softPedalNote } from "../../core/keys.ts";
29
32
  import { loopTicksOf } from "../../core/tempo.ts";
30
33
  import { BEND_SHAPES, barPedal } from "../commands/expression.ts";
31
34
  import type { AgentTool, ToolContext } from "./tools.ts";
@@ -333,4 +336,92 @@ export const EXPRESSION_TOOLS: readonly AgentTool[] = Object.freeze([
333
336
  };
334
337
  },
335
338
  },
339
+ {
340
+ // keys-electric (0.6.1): the modelled piano's soft and middle pedals.
341
+ name: "set_piano_pedals",
342
+ description:
343
+ 'Modelled piano soft (una corda: fewer strings, softer, darker) and sostenuto (holds only keys down when it presses) pedals. Each: [{beat, state}], "bars" (whole loop) or null. Sostenuto has no half.',
344
+ parameters: {
345
+ type: "object",
346
+ properties: {
347
+ trackId: { type: "string" },
348
+ soft: pedalSchema(PEDAL_STATES),
349
+ sostenuto: pedalSchema(SOSTENUTO_STATES),
350
+ },
351
+ additionalProperties: false,
352
+ },
353
+ plan(args, context) {
354
+ const trackId = trackOf(args, context);
355
+ const score = context.score;
356
+ const patch: Record<string, unknown> = {};
357
+ if (args.soft !== undefined)
358
+ patch.softPedal = pedalLane(args.soft, "soft", score, PEDAL_STATES);
359
+ if (args.sostenuto !== undefined)
360
+ patch.sostenuto = pedalLane(
361
+ args.sostenuto,
362
+ "sostenuto",
363
+ score,
364
+ SOSTENUTO_STATES,
365
+ );
366
+ if (Object.keys(patch).length === 0)
367
+ throw new ExpressionToolError("set soft, sostenuto or both");
368
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
369
+ const silent =
370
+ patch.softPedal && track ? softPedalNote(track) : undefined;
371
+ if (silent) throw new ExpressionToolError(`soft pedal · ${silent}`);
372
+ return {
373
+ kind: "score",
374
+ operations: [
375
+ { type: "updateTrack", trackId, patch: patch as TrackPatch },
376
+ ],
377
+ trackId,
378
+ summary: `${trackId} ${Object.entries(patch)
379
+ .map(([key, value]) => (value === null ? `${key} off` : key))
380
+ .join(", ")}`,
381
+ };
382
+ },
383
+ },
336
384
  ] satisfies AgentTool[]);
385
+
386
+ function pedalSchema(states: readonly string[]) {
387
+ return {
388
+ type: ["array", "string", "null"],
389
+ items: {
390
+ type: "object",
391
+ properties: {
392
+ beat: { type: "number" },
393
+ state: { type: "string", enum: states },
394
+ },
395
+ },
396
+ };
397
+ }
398
+
399
+ /** A soft or sostenuto lane from tool arguments: events, "bars" or null. */
400
+ function pedalLane(
401
+ value: unknown,
402
+ label: string,
403
+ score: ToolContext["score"],
404
+ states: readonly PedalState[],
405
+ ): unknown {
406
+ if (value === null) return null;
407
+ // Soft and sostenuto hold through the loop (no re-pedal at each bar).
408
+ if (value === "bars")
409
+ return [
410
+ { tick: 0, state: "down" },
411
+ { tick: loopTicksOf(score), state: "up" },
412
+ ];
413
+ if (!Array.isArray(value))
414
+ throw new ExpressionToolError(`${label} must be an array, "bars" or null`);
415
+ const events = value.map((item: unknown, index) => {
416
+ const event = (item ?? {}) as Record<string, unknown>;
417
+ const at = beat(event, "beat");
418
+ if (at === undefined)
419
+ throw new ExpressionToolError(`${label}[${index}].beat is required`);
420
+ return { tick: Math.round(at * score.ticksPerBeat), state: event.state };
421
+ });
422
+ return (
423
+ validated(() =>
424
+ normalizePedal(events, loopTicksOf(score), label, states),
425
+ ) ?? null
426
+ );
427
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Agent tool for the granular instrument: `set_granular` edits a track's
3
+ * `Track.granular` through the same path as the `grain` prompt command, so
4
+ * the command, the ctrl-k menu, the tool and the SDK store the same object.
5
+ */
6
+ import {
7
+ GRANULAR_PARAMS,
8
+ GRANULAR_PRESET_NAMES,
9
+ isGranularPreset,
10
+ synthSourceNames,
11
+ } from "../../core/granular.ts";
12
+ import { SCORE_LIMITS } from "../../core/score.ts";
13
+ import {
14
+ applyGranularCommand,
15
+ type GranularCommand,
16
+ } from "../commands/granular.ts";
17
+ import type { AgentTool, ToolContext } from "./tools.ts";
18
+
19
+ class GranularToolError extends Error {
20
+ constructor(message: string) {
21
+ super(message);
22
+ this.name = "ToolArgumentError";
23
+ }
24
+ }
25
+
26
+ function targetTrack(args: Record<string, unknown>, context: ToolContext) {
27
+ const trackId = args.trackId ?? context.focusedTrackId;
28
+ if (typeof trackId !== "string" || trackId.length > SCORE_LIMITS.maxIdLength)
29
+ throw new GranularToolError("trackId must be a short string");
30
+ if (!context.score.tracks.some((track) => track.id === trackId))
31
+ throw new GranularToolError(
32
+ `unknown track ${trackId}; create it with create_track first`,
33
+ );
34
+ return trackId;
35
+ }
36
+
37
+ /** One tool call becomes the `grain` commands it stands for, in order. */
38
+ export function granularToolCommands(
39
+ args: Record<string, unknown>,
40
+ ): GranularCommand[] {
41
+ const commands: GranularCommand[] = [];
42
+ if (args.off === true) return [{ type: "grain-off" }];
43
+ const voice =
44
+ typeof args.voice === "string" && args.voice.length > 0
45
+ ? args.voice.toLowerCase()
46
+ : undefined;
47
+ if (args.preset !== undefined) {
48
+ if (typeof args.preset !== "string" || !isGranularPreset(args.preset))
49
+ throw new GranularToolError(
50
+ `granular presets: ${GRANULAR_PRESET_NAMES.join(", ")}`,
51
+ );
52
+ commands.push({ type: "grain-preset", preset: args.preset, voice });
53
+ } else if (args.reset === true) commands.push({ type: "grain-reset" });
54
+ else commands.push({ type: "grain-on", voice });
55
+ if (args.src !== undefined) {
56
+ if (typeof args.src !== "string")
57
+ throw new GranularToolError("src must be synth:<name>[@note]");
58
+ const text = args.src.startsWith("synth:") ? args.src : `synth:${args.src}`;
59
+ commands.push({ type: "grain-src", synth: text.toLowerCase() });
60
+ }
61
+ if (args.params !== undefined) {
62
+ if (
63
+ typeof args.params !== "object" ||
64
+ args.params === null ||
65
+ Array.isArray(args.params)
66
+ )
67
+ throw new GranularToolError("params must be an object");
68
+ const values: Record<string, number | string | boolean | null> = {};
69
+ for (const [name, value] of Object.entries(args.params)) {
70
+ if (!Object.prototype.hasOwnProperty.call(GRANULAR_PARAMS, name))
71
+ throw new GranularToolError(
72
+ `granular has no parameter ${name}; it takes ${Object.keys(GRANULAR_PARAMS).join(", ")}`,
73
+ );
74
+ if (
75
+ value !== null &&
76
+ typeof value !== "number" &&
77
+ typeof value !== "string" &&
78
+ typeof value !== "boolean"
79
+ )
80
+ throw new GranularToolError(`granular ${name} must be a value`);
81
+ values[name] = value;
82
+ }
83
+ commands.push({ type: "grain-set", values });
84
+ }
85
+ return commands;
86
+ }
87
+
88
+ export const GRANULAR_TOOLS: readonly AgentTool[] = Object.freeze([
89
+ {
90
+ name: "set_granular",
91
+ description: `Make a track a granular instrument (offline, nothing to download) or shape it. preset: ${GRANULAR_PRESET_NAMES.join(" ")}. src: a built-in synth source synth:<${synthSourceNames().slice(0, 8).join("|")}|…>[@note]; on a sampler track the first (or named voice) sample is the source. params (null unsets): grain s, overlap, scan (head speed, 0 held), pos, begin, end, spray, jitter, pitch st, detune, shimmer, shimint, spread, window (hann tukey gauss tri perc rperc), reverse 0..1, freeze, repeat, hold, drift, drate, attack, release, veltone, gain, seed, root. reset keeps preset and source; off returns to the previous voice.`,
92
+ parameters: {
93
+ type: "object",
94
+ properties: {
95
+ trackId: { type: "string" },
96
+ preset: { type: "string", enum: [...GRANULAR_PRESET_NAMES] },
97
+ src: { type: "string" },
98
+ voice: { type: "string" },
99
+ reset: { type: "boolean" },
100
+ off: { type: "boolean" },
101
+ params: {
102
+ type: "object",
103
+ additionalProperties: {
104
+ type: ["number", "string", "boolean", "null"],
105
+ },
106
+ },
107
+ },
108
+ additionalProperties: false,
109
+ },
110
+ plan(args, context) {
111
+ const trackId = targetTrack(args, context);
112
+ let score = context.score;
113
+ let message = "";
114
+ for (const command of granularToolCommands(args)) {
115
+ const result = applyGranularCommand(score, trackId, command);
116
+ if (!result.ok) throw new GranularToolError(result.message);
117
+ if (result.next) score = result.next;
118
+ message = result.message;
119
+ }
120
+ const next = score.tracks.find((t) => t.id === trackId)!;
121
+ return {
122
+ kind: "score",
123
+ operations: [
124
+ {
125
+ type: "updateTrack",
126
+ trackId,
127
+ patch: {
128
+ instrument: next.instrument,
129
+ granular: next.granular ?? null,
130
+ },
131
+ },
132
+ ],
133
+ trackId,
134
+ summary: `${trackId} ${message}`,
135
+ };
136
+ },
137
+ },
138
+ ]);
@@ -486,17 +486,17 @@ export async function loadLiveCatalog(
486
486
  * and `chatTools()` serialized, plus `compositionBrief` of a 3-track, 48-note
487
487
  * loop; see models.test.ts, which keeps these in step with the code):
488
488
  *
489
- * - system prompt ≈ 4.3 KB, tool schemas ≈ 38 KB, brief ≈ 1.6 KB typical
490
- * (capped at 12 KiB), user request ≈ 0.1 KB → ≈ 44 KB ≈ 11,000 tokens
489
+ * - system prompt ≈ 7 KB, tool schemas ≈ 51 KB, brief ≈ 1.6 KB typical
490
+ * (capped at 12 KiB), user request ≈ 0.1 KB → ≈ 59 KB ≈ 14,800 tokens
491
491
  * per request at ~4 bytes per token;
492
492
  * - about 2 requests per prompt (tool calls, then a short reply), the second
493
493
  * also carrying the first step's tool calls and results (≈ 0.6 KB);
494
494
  * - output ≈ 450 tokens of tool-call arguments and ≈ 150 of reply.
495
495
  *
496
- * So ≈ 22,100 input and ≈ 600 output tokens per prompt.
496
+ * So ≈ 29,800 input and ≈ 600 output tokens per prompt.
497
497
  */
498
498
  export const TYPICAL_PROMPT = Object.freeze({
499
- inputTokens: 22_100,
499
+ inputTokens: 29_800,
500
500
  outputTokens: 600,
501
501
  });
502
502
 
package/src/agent/ops.ts CHANGED
@@ -1,4 +1,10 @@
1
+ import { resolveInstrumentWord } from "../../core/instruments.ts";
1
2
  import { pitchToMidi } from "../../core/pitch.ts";
3
+ import {
4
+ instrumentPatchForWord,
5
+ type TrackModal,
6
+ type TrackWind,
7
+ } from "../../core/resonators.ts";
2
8
  import { SCORE_LIMITS } from "../../core/score.ts";
3
9
 
4
10
  export { pitchToMidi };
@@ -34,11 +40,16 @@ export type AgentOperation =
34
40
  type: "track";
35
41
  patch: {
36
42
  instrument?: string;
43
+ string?: { preset: string };
44
+ /** 0.6: a modal preset word (`instrument vibes`) sets this too. */
45
+ modal?: TrackModal;
37
46
  muted?: boolean;
38
47
  volume?: number;
39
48
  pan?: number;
40
49
  name?: string;
41
50
  };
51
+ /** The instrument word as typed (a guitar alias also loads its rig). */
52
+ word?: string;
42
53
  }
43
54
  | {
44
55
  type: "automation";
@@ -74,8 +85,14 @@ export function parsePrompt(prompt: string): AgentOperation | undefined {
74
85
  const instrument = text.match(
75
86
  /^(?:instrument|sound|voice)\s+([a-z0-9._ -]{1,64})$/,
76
87
  );
77
- if (instrument)
78
- return { type: "track", patch: { instrument: instrument[1]!.trim() } };
88
+ if (instrument) {
89
+ const word = instrument[1]!.trim();
90
+ return {
91
+ type: "track",
92
+ patch: instrumentPatch(word),
93
+ ...(resolveInstrumentWord(word)?.fx ? { word } : {}),
94
+ };
95
+ }
79
96
  if (/^(?:mute|silence)\b/.test(text))
80
97
  return { type: "track", patch: { muted: true } };
81
98
  if (/^(?:unmute|unsilence)\b/.test(text))
@@ -180,3 +197,26 @@ export function parsePrompt(prompt: string): AgentOperation | undefined {
180
197
  ...(offset !== 0 ? { cents: offset } : {}),
181
198
  };
182
199
  }
200
+
201
+ /**
202
+ * The track patch an instrument word means: a 0.6 resolver word for the
203
+ * string engine also writes its preset (`instrument nylon` plays the nylon
204
+ * string); every other word stores the instrument as before.
205
+ */
206
+ export function instrumentPatch(word: string): {
207
+ instrument: string;
208
+ string?: { preset: string };
209
+ modal?: TrackModal;
210
+ wind?: TrackWind;
211
+ } {
212
+ const meaning = resolveInstrumentWord(word);
213
+ if (!meaning) return { instrument: word };
214
+ if (meaning.field === "modal" || meaning.field === "wind")
215
+ return instrumentPatchForWord(word);
216
+ if (meaning.field === "string" && meaning.preset)
217
+ return {
218
+ instrument: meaning.instrument,
219
+ string: { preset: meaning.preset },
220
+ };
221
+ return { instrument: meaning.instrument };
222
+ }