@hraness/dawg 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/DAWG.md +605 -39
  3. package/README.md +4 -4
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +13 -1
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/kits.ts +320 -0
  9. package/core/params.ts +111 -0
  10. package/core/rhythm.ts +287 -0
  11. package/core/score.ts +735 -18
  12. package/core/sdk/eval-child.ts +34 -22
  13. package/core/sdk/eval.ts +4 -1
  14. package/core/sdk/print.ts +304 -22
  15. package/core/sdk/sync-chords.ts +54 -0
  16. package/core/sdk/v1.ts +3226 -21
  17. package/core/synth.ts +1001 -0
  18. package/package.json +1 -1
  19. package/src/agent/agent.ts +38 -3
  20. package/src/agent/brief.ts +29 -4
  21. package/src/agent/chord-tools.ts +357 -0
  22. package/src/agent/drum-tools.ts +135 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/pack-tools.ts +369 -0
  25. package/src/agent/planner.ts +17 -2
  26. package/src/agent/preview-tool.ts +270 -0
  27. package/src/agent/rhythm-tools.ts +145 -0
  28. package/src/agent/tools.ts +308 -14
  29. package/src/agent/xcb-agent.ts +8 -2
  30. package/src/audio/audition.ts +93 -0
  31. package/src/audio/cache.ts +160 -0
  32. package/src/audio/effects/bus.ts +147 -0
  33. package/src/audio/effects/chain.ts +99 -0
  34. package/src/audio/effects/common.ts +251 -0
  35. package/src/audio/effects/convolution.ts +301 -0
  36. package/src/audio/effects/drive.ts +142 -0
  37. package/src/audio/effects/duck.ts +122 -0
  38. package/src/audio/effects/dynamics.ts +121 -0
  39. package/src/audio/effects/filter.ts +319 -0
  40. package/src/audio/effects/modulation.ts +174 -0
  41. package/src/audio/effects/space.ts +337 -0
  42. package/src/audio/engine.ts +27 -4
  43. package/src/audio/kits.ts +200 -0
  44. package/src/audio/packs.ts +1787 -0
  45. package/src/audio/preview.ts +470 -0
  46. package/src/audio/random.ts +15 -0
  47. package/src/audio/sampler.ts +157 -29
  48. package/src/audio/samples.ts +386 -44
  49. package/src/audio/synth/oscillators.ts +268 -0
  50. package/src/audio/synth/voice.ts +555 -0
  51. package/src/audio/synth/zzfx.ts +137 -0
  52. package/src/audio/wav.ts +280 -278
  53. package/src/audio/wavetable-maker.ts +717 -0
  54. package/src/audio/wavetable.ts +624 -0
  55. package/src/commands/drums.ts +299 -0
  56. package/src/commands/edit.ts +26 -4
  57. package/src/commands/fx.ts +360 -0
  58. package/src/commands/help.ts +298 -10
  59. package/src/commands/music.ts +10 -8
  60. package/src/commands/pack.ts +423 -0
  61. package/src/commands/rhythm.ts +230 -0
  62. package/src/commands/sample.ts +162 -2
  63. package/src/commands/synth.ts +229 -0
  64. package/src/commands/wavetable.ts +370 -0
  65. package/src/main.ts +1119 -30
  66. package/src/media/cli.ts +15 -2
  67. package/src/media/tools.ts +106 -1
  68. package/src/media/wavetable.ts +202 -0
  69. package/src/render.ts +22 -1
  70. package/src/session/naming.ts +3 -1
  71. package/src/tui/audition.ts +501 -0
  72. package/src/tui/euclid.ts +472 -0
  73. package/src/tui/menu.ts +1286 -244
  74. package/src/tui/play-chords.ts +538 -0
  75. package/src/tui/play-session.ts +364 -16
  76. package/src/tui/sketch.ts +108 -0
  77. package/src/web/search.ts +4 -1
  78. package/tui/app.ts +161 -39
  79. package/tui/grammar.ts +281 -0
  80. package/tui/highway.ts +7 -1
  81. package/tui/play-strip.ts +59 -14
@@ -1,13 +1,50 @@
1
1
  import {
2
- AUTOMATION_LANES,
2
+ AUTOMATION_PARAMETERS,
3
+ automationPoints,
4
+ automationRange,
3
5
  SCORE_LIMITS,
4
6
  type AutomationParameter,
5
7
  type ScoreOperation,
6
8
  type TrackScore,
7
9
  } from "../../core/score.ts";
8
10
  import { AVAILABLE_INSTRUMENTS } from "../audio/wav.ts";
11
+ import {
12
+ SYNTH_PRESETS,
13
+ SYNTH_SIMPLE,
14
+ isSynthPreset,
15
+ synthParamName,
16
+ } from "../../core/synth.ts";
17
+ import { applySynthCommand, type SynthCommand } from "../commands/synth.ts";
18
+ import {
19
+ setSampleControls,
20
+ type SampleControlValue,
21
+ } from "../commands/sample.ts";
22
+ import {
23
+ EFFECT_NAMES,
24
+ FX_PRESETS,
25
+ effectSpec,
26
+ type EffectName,
27
+ } from "../../core/fx.ts";
28
+ import {
29
+ applyFxCommand,
30
+ effectPatch,
31
+ effectValues,
32
+ parseEffectName,
33
+ parseParamName,
34
+ type FxCommand,
35
+ } from "../commands/fx.ts";
9
36
  import type { ChatTool } from "./gateway.ts";
10
37
  import { MEDIA_TOOLS } from "../media/tools.ts";
38
+ import { PACK_TOOLS, PackToolError } from "./pack-tools.ts";
39
+ import {
40
+ PreviewToolError,
41
+ previewSoundTool,
42
+ type PreviewHost,
43
+ } from "./preview-tool.ts";
44
+ import { PackError, type PackStore } from "../audio/packs.ts";
45
+ import { RHYTHM_TOOLS } from "./rhythm-tools.ts";
46
+ import { CHORD_TOOLS } from "./chord-tools.ts";
47
+ import { DRUM_TOOLS } from "./drum-tools.ts";
11
48
  import type { MediaResult, MediaRunContext } from "../media/types.ts";
12
49
  import { pitchToMidi } from "./ops.ts";
13
50
  import {
@@ -43,6 +80,13 @@ import {
43
80
  } from "../web/search.ts";
44
81
 
45
82
  /** What a validated tool call asks the host to do. */
83
+ export type ScorePlan = Readonly<{
84
+ kind: "score";
85
+ operations: readonly ScoreOperation[];
86
+ summary: string;
87
+ trackId?: string;
88
+ }>;
89
+
46
90
  export type ToolPlan =
47
91
  | Readonly<{
48
92
  kind: "score";
@@ -62,6 +106,15 @@ export type ToolPlan =
62
106
  /** Side effects outside the score (files, network); bounded and async. */
63
107
  run: (context: ActionContext) => Promise<ActionResult>;
64
108
  }>
109
+ /**
110
+ * Score edits that need async work first (fetching a pack sound); `run`
111
+ * returns the score plan, committed like any other.
112
+ */
113
+ | Readonly<{
114
+ kind: "prepare";
115
+ summary: string;
116
+ run: (context: ActionContext) => Promise<ScorePlan>;
117
+ }>
65
118
  /** A long-running local media job (download, stems, analysis, …). */
66
119
  | Readonly<{
67
120
  kind: "media";
@@ -105,6 +158,10 @@ export type ActionContext = Readonly<{
105
158
  onWorkspaceWrite?: (path: string) => Promise<string | void> | string | void;
106
159
  web?: WebHost;
107
160
  signal?: AbortSignal;
161
+ /** Sample packs for list_packs/search_sounds/use_sound; default the user cache. */
162
+ packs?: PackStore;
163
+ /** Render and play hooks for preview_sound; absent renders in-thread, silent. */
164
+ preview?: PreviewHost;
108
165
  }>;
109
166
 
110
167
  export type ActionResult = Readonly<{
@@ -139,10 +196,6 @@ export class ToolArgumentError extends Error {
139
196
  }
140
197
  }
141
198
 
142
- const AUTOMATION_PARAMETERS = Object.keys(
143
- AUTOMATION_LANES,
144
- ) as AutomationParameter[];
145
-
146
199
  const MAX_NOTES_PER_CALL = 128;
147
200
  const MAX_EXPLAIN_CHARS = 2_000;
148
201
  const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
@@ -422,12 +475,16 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
422
475
  },
423
476
  {
424
477
  name: "set_automation",
425
- description: `Write an automation lane: volume (0..1), pan (-1..1), filter cutoff (${SCORE_LIMITS.minFilterCutoff}..${SCORE_LIMITS.maxFilterCutoff} Hz low-pass), resonance (0..${SCORE_LIMITS.maxFilterResonance}), delay-feedback (0..${SCORE_LIMITS.maxDelayFeedback}) or delay-mix (0..${SCORE_LIMITS.maxDelayMix}). Delay lanes need a delay on the track (set_effects) to be heard. mode=replace (default) rewrites the lane; merge keeps existing points at other beats. An empty replace clears it.`,
478
+ description: `Write an automation lane: volume 0..1, pan -1..1, filter (cutoff Hz), resonance 0..${SCORE_LIMITS.maxFilterResonance}, delay-feedback 0..${SCORE_LIMITS.maxDelayFeedback}, delay-mix, wt (wavetable position 0..1), or <effect>-<param> (the param's range). The effect must be on to be heard. mode=replace (default) rewrites the lane; merge keeps other beats. An empty replace clears it.`,
426
479
  parameters: {
427
480
  type: "object",
428
481
  properties: {
429
482
  trackId: trackIdSchema,
430
- parameter: { type: "string", enum: AUTOMATION_PARAMETERS },
483
+ parameter: {
484
+ type: "string",
485
+ description:
486
+ "volume, pan, filter, resonance, delay-feedback, delay-mix, or <effect>-<param>",
487
+ },
431
488
  mode: { type: "string", enum: ["replace", "merge"] },
432
489
  points: {
433
490
  type: "array",
@@ -438,8 +495,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
438
495
  beat: beatSchema("Beat position"),
439
496
  value: {
440
497
  type: "number",
441
- description:
442
- "volume 0..1, pan -1..1, filter cutoff in Hz, resonance 0..1, delay-feedback 0..0.9, delay-mix 0..1",
498
+ description: "in the lane's range",
443
499
  },
444
500
  },
445
501
  required: ["beat", "value"],
@@ -461,7 +517,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
461
517
  args.mode === undefined
462
518
  ? "replace"
463
519
  : oneOf(args.mode, ["replace", "merge"], "mode");
464
- const { min, max } = AUTOMATION_LANES[parameter];
520
+ const { min, max } = automationRange(parameter);
465
521
  const tpb = context.score.ticksPerBeat;
466
522
  const points = list(
467
523
  args,
@@ -476,7 +532,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
476
532
  };
477
533
  });
478
534
  const track = context.score.tracks.find((t) => t.id === trackId)!;
479
- const existing = track[AUTOMATION_LANES[parameter].field] ?? [];
535
+ const existing = automationPoints(track, parameter);
480
536
  const merged = Array.from(
481
537
  new Map(
482
538
  [...(mode === "merge" ? existing : []), ...points].map((point) => [
@@ -498,9 +554,153 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
498
554
  };
499
555
  },
500
556
  },
557
+ {
558
+ name: "set_fx",
559
+ description:
560
+ "Turn an effect on/off, load a preset, or set params by dawg or Strudel name (lpq, delayfeedback…). on:true uses good defaults; effects and presets are in the brief.",
561
+ parameters: {
562
+ type: "object",
563
+ properties: {
564
+ trackId: trackIdSchema,
565
+ effect: { type: "string", description: "see the brief's effects" },
566
+ on: { type: "boolean" },
567
+ preset: { type: "string" },
568
+ params: {
569
+ type: "object",
570
+ additionalProperties: { type: ["number", "string", "boolean"] },
571
+ },
572
+ },
573
+ required: ["effect"],
574
+ additionalProperties: false,
575
+ },
576
+ plan(args, context) {
577
+ const trackId = targetTrack(args, context);
578
+ const effect =
579
+ typeof args.effect === "string"
580
+ ? parseEffectName(args.effect)
581
+ : undefined;
582
+ if (!effect)
583
+ throw new ToolArgumentError(
584
+ `effect must be one of ${EFFECT_NAMES.join(", ")}`,
585
+ );
586
+ const command = fxToolCommand(effect, args);
587
+ const result = applyFxCommand(context.score, trackId, command);
588
+ if (!result.ok || !result.next)
589
+ throw new ToolArgumentError(result.message);
590
+ const track = context.score.tracks.find((t) => t.id === trackId)!;
591
+ const values = effectValues(
592
+ result.next.tracks.find((t) => t.id === trackId),
593
+ effect,
594
+ );
595
+ return {
596
+ kind: "score",
597
+ operations: [
598
+ {
599
+ type: "updateTrack",
600
+ trackId,
601
+ patch: effectPatch(track, effect, values ?? null),
602
+ },
603
+ ],
604
+ trackId,
605
+ summary: `${trackId} ${result.message}`,
606
+ };
607
+ },
608
+ },
609
+ {
610
+ name: "set_synth",
611
+ description:
612
+ "Shape a synth track's voice with Strudel synth params (attack decay sustain release, lpf lpq lpenv, fm fmh, unison detune spread, vib vibmod, penv, noise, pw…); null unsets one. preset loads a voice (instrument + params); reset clears all; zzfx takes a raw ZzFX array (empty slots null) and sets a z_* sound.",
613
+ parameters: {
614
+ type: "object",
615
+ properties: {
616
+ trackId: trackIdSchema,
617
+ preset: { type: "string", enum: Object.keys(SYNTH_PRESETS) },
618
+ reset: { type: "boolean" },
619
+ zzfx: {
620
+ type: "array",
621
+ maxItems: 21,
622
+ items: { type: ["number", "null"] },
623
+ },
624
+ params: {
625
+ type: "object",
626
+ additionalProperties: {
627
+ type: ["number", "string", "boolean", "array", "null"],
628
+ },
629
+ },
630
+ },
631
+ additionalProperties: false,
632
+ },
633
+ plan(args, context) {
634
+ const trackId = targetTrack(args, context);
635
+ const command = synthToolCommand(args);
636
+ const result = applySynthCommand(context.score, trackId, command);
637
+ if (!result.ok || !result.next)
638
+ throw new ToolArgumentError(result.message);
639
+ const next = result.next.tracks.find((t) => t.id === trackId)!;
640
+ return {
641
+ kind: "score",
642
+ operations: [
643
+ {
644
+ type: "updateTrack",
645
+ trackId,
646
+ patch: { instrument: next.instrument, synth: next.synth ?? null },
647
+ },
648
+ ],
649
+ trackId,
650
+ summary: `${trackId} ${result.message}`,
651
+ };
652
+ },
653
+ },
654
+ {
655
+ name: "set_sample",
656
+ description:
657
+ "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.",
658
+ parameters: {
659
+ type: "object",
660
+ properties: {
661
+ trackId: trackIdSchema,
662
+ voice: { type: "string" },
663
+ params: {
664
+ type: "object",
665
+ additionalProperties: {
666
+ type: ["number", "string", "boolean", "null"],
667
+ },
668
+ },
669
+ },
670
+ required: ["voice", "params"],
671
+ additionalProperties: false,
672
+ },
673
+ plan(args, context) {
674
+ const trackId = targetTrack(args, context);
675
+ const params = args.params;
676
+ if (
677
+ typeof args.voice !== "string" ||
678
+ typeof params !== "object" ||
679
+ params === null ||
680
+ Array.isArray(params)
681
+ )
682
+ throw new ToolArgumentError("set_sample needs voice and params");
683
+ const result = setSampleControls(
684
+ context.score,
685
+ trackId,
686
+ args.voice,
687
+ params as Record<string, SampleControlValue>,
688
+ );
689
+ if (!result.ok) throw new ToolArgumentError(result.message);
690
+ const next = result.next.tracks.find((t) => t.id === trackId)!;
691
+ return {
692
+ kind: "score",
693
+ operations: [
694
+ { type: "updateTrack", trackId, patch: { sampler: next.sampler } },
695
+ ],
696
+ trackId,
697
+ summary: `${trackId} ${result.message}`,
698
+ };
699
+ },
700
+ },
501
701
  {
502
702
  name: "set_effects",
503
- description: `Set or remove a track's low-pass filter, tempo-synced stereo delay, and stereo reverb send. Pass null to remove an effect. filter: cutoff ${SCORE_LIMITS.minFilterCutoff}..${SCORE_LIMITS.maxFilterCutoff} Hz, resonance 0..${SCORE_LIMITS.maxFilterResonance}. delay: beats ${SCORE_LIMITS.minDelayBeats}..${SCORE_LIMITS.maxDelayBeats}, feedback 0..${SCORE_LIMITS.maxDelayFeedback}, mix 0..${SCORE_LIMITS.maxDelayMix}. reverb: mix 0..${SCORE_LIMITS.maxReverbMix} (0.15..0.35 is a natural room), size ${SCORE_LIMITS.minReverbSize}..${SCORE_LIMITS.maxReverbSize} (default 0.5; higher is a longer, darker tail).`,
703
+ description: `Shorthand: low-pass filter, delay (beats) and reverb (mix 0.15..0.35 is a room); null removes one. Prefer set_fx.`,
504
704
  parameters: {
505
705
  type: "object",
506
706
  properties: {
@@ -660,7 +860,7 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
660
860
  },
661
861
  {
662
862
  name: "add_drums",
663
- description: `Add drum hits to a kit track (create one with create_track instrument "kit"). Voices: ${DRUM_VOICES.map((info) => info.voice).join(", ")}. Give explicit hits, and/or patterns that repeat a voice every N beats across the loop.`,
863
+ description: `Add one-off drum hits to a kit track (create one with create_track instrument "kit"); prefer set_rhythm for repeating beats. Voices: ${DRUM_VOICES.map((info) => info.voice).join(", ")}. Give explicit hits, and/or patterns that repeat a voice every N beats across the loop.`,
664
864
  parameters: {
665
865
  type: "object",
666
866
  properties: {
@@ -1148,7 +1348,13 @@ export const AGENT_TOOLS: readonly AgentTool[] = Object.freeze([
1148
1348
  };
1149
1349
  },
1150
1350
  },
1351
+ ...RHYTHM_TOOLS,
1352
+ ...CHORD_TOOLS,
1353
+ ...DRUM_TOOLS,
1151
1354
  ...MEDIA_TOOLS,
1355
+ ...PACK_TOOLS,
1356
+ // Looks tools up at call time, so it can plan any of the above.
1357
+ previewSoundTool((name) => findAgentTool(name)),
1152
1358
  ] satisfies AgentTool[]);
1153
1359
 
1154
1360
  /** Errors an `action` plan may raise that are safe to show to the model. */
@@ -1156,7 +1362,10 @@ export function isActionDiagnostic(error: unknown): boolean {
1156
1362
  return (
1157
1363
  error instanceof WorkspaceError ||
1158
1364
  error instanceof WebError ||
1159
- error instanceof ToolArgumentError
1365
+ error instanceof ToolArgumentError ||
1366
+ error instanceof PackToolError ||
1367
+ error instanceof PreviewToolError ||
1368
+ error instanceof PackError
1160
1369
  );
1161
1370
  }
1162
1371
 
@@ -1250,6 +1459,91 @@ export function chatTools(
1250
1459
  }));
1251
1460
  }
1252
1461
 
1462
+ function fxToolCommand(
1463
+ effect: EffectName,
1464
+ args: Record<string, unknown>,
1465
+ ): FxCommand {
1466
+ if (args.on === false) return { type: "fx-off", effect };
1467
+ if (args.preset !== undefined) {
1468
+ if (
1469
+ typeof args.preset !== "string" ||
1470
+ !Object.prototype.hasOwnProperty.call(
1471
+ FX_PRESETS[effect] ?? {},
1472
+ args.preset,
1473
+ )
1474
+ )
1475
+ throw new ToolArgumentError(
1476
+ `${effect} presets: ${Object.keys(FX_PRESETS[effect] ?? {}).join(", ") || "none"}`,
1477
+ );
1478
+ return { type: "fx-preset", effect, preset: args.preset };
1479
+ }
1480
+ if (args.params === undefined) {
1481
+ if (args.on === true) return { type: "fx-on", effect };
1482
+ throw new ToolArgumentError("set_fx needs on, preset, or params");
1483
+ }
1484
+ const params = record(args.params, "params");
1485
+ const values: Record<string, number | string | boolean> = {};
1486
+ for (const [name, value] of Object.entries(params)) {
1487
+ const param = parseParamName(effect, name);
1488
+ if (!param)
1489
+ throw new ToolArgumentError(
1490
+ `${effect} has no parameter ${name}; it takes ${Object.keys(effectSpec(effect).params).join(", ")}`,
1491
+ );
1492
+ if (
1493
+ typeof value !== "number" &&
1494
+ typeof value !== "string" &&
1495
+ typeof value !== "boolean"
1496
+ )
1497
+ throw new ToolArgumentError(`${effect} ${name} must be a value`);
1498
+ values[param] = value;
1499
+ }
1500
+ return { type: "fx-set", effect, values };
1501
+ }
1502
+
1503
+ function synthToolCommand(args: Record<string, unknown>): SynthCommand {
1504
+ if (args.reset === true) return { type: "synth-reset" };
1505
+ if (args.zzfx !== undefined) {
1506
+ if (
1507
+ !Array.isArray(args.zzfx) ||
1508
+ args.zzfx.length > 21 ||
1509
+ !args.zzfx.every((n) => n === null || typeof n === "number")
1510
+ )
1511
+ throw new ToolArgumentError("zzfx must be up to 21 numbers or nulls");
1512
+ return { type: "synth-zzfx", values: args.zzfx as (number | null)[] };
1513
+ }
1514
+ if (args.preset !== undefined) {
1515
+ if (typeof args.preset !== "string" || !isSynthPreset(args.preset))
1516
+ throw new ToolArgumentError(
1517
+ `synth presets: ${Object.keys(SYNTH_PRESETS).join(", ")}`,
1518
+ );
1519
+ return { type: "synth-preset", preset: args.preset };
1520
+ }
1521
+ if (args.params === undefined)
1522
+ throw new ToolArgumentError("set_synth needs preset, reset, or params");
1523
+ const params = record(args.params, "params");
1524
+ const values: Record<
1525
+ string,
1526
+ number | string | boolean | readonly number[] | null
1527
+ > = {};
1528
+ for (const [name, value] of Object.entries(params)) {
1529
+ const param = synthParamName(name);
1530
+ if (!param)
1531
+ throw new ToolArgumentError(
1532
+ `synth has no parameter ${name}; basics: ${SYNTH_SIMPLE.join(", ")} (DAWG.md lists all)`,
1533
+ );
1534
+ if (
1535
+ value !== null &&
1536
+ typeof value !== "number" &&
1537
+ typeof value !== "string" &&
1538
+ typeof value !== "boolean" &&
1539
+ !(Array.isArray(value) && value.every((n) => typeof n === "number"))
1540
+ )
1541
+ throw new ToolArgumentError(`synth ${name} must be a value`);
1542
+ values[param] = value as number;
1543
+ }
1544
+ return { type: "synth-set", values };
1545
+ }
1546
+
1253
1547
  function targetTrack(args: Record<string, unknown>, context: ToolContext) {
1254
1548
  const trackId = args.trackId ?? context.focusedTrackId;
1255
1549
  if (typeof trackId !== "string" || trackId.length > SCORE_LIMITS.maxIdLength)
@@ -1,4 +1,5 @@
1
1
  import { compositionBrief } from "./brief.ts";
2
+ import { CHORD_PROCESS } from "../../core/chords.ts";
2
3
  import {
3
4
  AGENT_LIMITS,
4
5
  MEDIA_PROMPT,
@@ -44,8 +45,13 @@ export const TEXT_AGENT_SYSTEM_PROMPT = [
44
45
  "You edit the score by returning operations. Each op is validated and applied immediately, in order, as its own revision.",
45
46
  "Times are in beats from the loop start (0-based). Keep notes inside loopBeats unless you extend the loop first.",
46
47
  "Prefer a few well-formed ops (one add_notes op per track part) over many tiny ones.",
47
- 'For drums, create a track with instrument "kit" and use add_drums; drum pitches select voices, so do not use add_notes for beats.',
48
- "Effects (set_effects, set_automation): low-pass filter cutoff 20..20000 Hz and resonance 0..1; stereo delay beats 0.0625..4, feedback 0..0.9, mix 0..1; stereo reverb mix 0..1 (0.15..0.35 is a natural room) and size 0..1; pan -1..1 is equal-power stereo. Automatable lanes: volume, pan, filter, resonance, delay-feedback, delay-mix.",
48
+ 'For drums, create a track with instrument "kit" and prefer set_rhythm (Euclidean rows: pulses over steps, rotate, repeats for rolls, accent, probability, swing) so the beat stays editable as parameters; for a genre groove start from apply_drum_pattern (list_drum_patterns) and pick a sound with set_drum_kit; use add_drums only for one-off fills. Drum pitches select voices, so do not use add_notes for beats.',
49
+ CHORD_PROCESS,
50
+ "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…).",
51
+ "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.",
52
+ "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).",
53
+ "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.",
54
+ "Wavetable synth: set_wavetable picks a table (built-ins offline, Strudel wt_ sets fetched once) and scans it by position wt.",
49
55
  '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}.',
50
56
  '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
57
  "If an op was rejected, read its diagnostic and either send a corrected op or stop with done:true.",
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Audition: a short phrase of one track (or some of its pitches) rendered
3
+ * through the offline renderer, as a live voice the engine can mix over
4
+ * silence. The Euclidean editor plays the edited voice's lane once after
5
+ * every change, and the pattern picker previews a pattern before it lands.
6
+ *
7
+ * Like `live.ts` this only builds a smaller score; the renderer is shared,
8
+ * so an audition sounds exactly like the loop.
9
+ */
10
+ import {
11
+ SCORE_LIMITS,
12
+ TrackScore,
13
+ type Note,
14
+ type Track,
15
+ } from "../../core/score.ts";
16
+ import type { LiveNotePcm } from "./live.ts";
17
+ import type { SampleBank } from "./samples.ts";
18
+ import { RENDER_CHANNELS, renderScorePcm } from "./wav.ts";
19
+
20
+ export type AuditionRequest = Readonly<{
21
+ score: TrackScore;
22
+ trackId: string;
23
+ /** Only these pitches (one drum voice); all of the track's notes when absent. */
24
+ pitches?: ReadonlySet<number>;
25
+ /** Bars rendered from the loop start (default 1, at most the loop). */
26
+ bars?: number;
27
+ sampleRate: number;
28
+ samples?: SampleBank;
29
+ }>;
30
+
31
+ /** Longest audition, so a slow tempo cannot hold a voice for long. */
32
+ export const MAX_AUDITION_SECONDS = 6;
33
+
34
+ export function renderAudition(
35
+ request: AuditionRequest,
36
+ ): LiveNotePcm | undefined {
37
+ const { score } = request;
38
+ const track = score.tracks.find(
39
+ (candidate) => candidate.id === request.trackId,
40
+ );
41
+ if (!track) return undefined;
42
+ const secondsPerBar = (score.beatsPerBar * 60) / score.tempoBpm;
43
+ const bars = Math.max(
44
+ 1,
45
+ Math.min(
46
+ score.bars,
47
+ request.bars ?? 1,
48
+ Math.floor(MAX_AUDITION_SECONDS / secondsPerBar) || 1,
49
+ ),
50
+ );
51
+ const endTick = bars * score.beatsPerBar * score.ticksPerBeat;
52
+ const notes: Note[] = score.notes.filter(
53
+ (note) =>
54
+ note.trackId === track.id &&
55
+ note.startTick < endTick &&
56
+ (!request.pitches || request.pitches.has(note.pitch)),
57
+ );
58
+ if (notes.length === 0) return undefined;
59
+ const single = new TrackScore({
60
+ tempoBpm: score.tempoBpm,
61
+ beatsPerBar: score.beatsPerBar,
62
+ bars,
63
+ ticksPerBeat: score.ticksPerBeat,
64
+ tracks: [auditionTrack(track)],
65
+ notes: notes.map((note) => ({
66
+ ...note,
67
+ durationTicks: Math.max(
68
+ 1,
69
+ Math.min(
70
+ note.durationTicks,
71
+ endTick - note.startTick,
72
+ SCORE_LIMITS.maxTick,
73
+ ),
74
+ ),
75
+ })),
76
+ });
77
+ const audio = renderScorePcm(single, {
78
+ sampleRate: request.sampleRate,
79
+ maxSeconds: MAX_AUDITION_SECONDS + 4,
80
+ ...(request.samples ? { samples: request.samples } : {}),
81
+ });
82
+ let last = audio.pcm.length - 1;
83
+ while (last >= 0 && audio.pcm[last] === 0) last -= 1;
84
+ const frames = Math.ceil((last + 1) / RENDER_CHANNELS);
85
+ if (frames === 0) return undefined;
86
+ return { pcm: audio.pcm.subarray(0, frames * RENDER_CHANNELS), frames };
87
+ }
88
+
89
+ /** Audible regardless of mute and solo; the generator rows are not needed. */
90
+ function auditionTrack(track: Track): Track {
91
+ const { solo: _solo, rhythm: _rhythm, ...rest } = track;
92
+ return { ...rest, muted: false };
93
+ }