@hraness/dawg 0.3.0 → 0.4.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.
- package/CHANGELOG.md +138 -0
- package/DAWG.md +608 -39
- package/README.md +4 -4
- package/core/chords.ts +1724 -0
- package/core/diff.ts +13 -1
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +735 -18
- package/core/sdk/eval-child.ts +34 -22
- package/core/sdk/eval.ts +4 -1
- package/core/sdk/print.ts +304 -22
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +3226 -21
- package/core/synth.ts +1001 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +38 -3
- package/src/agent/brief.ts +29 -4
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +17 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/tools.ts +308 -14
- package/src/agent/xcb-agent.ts +8 -2
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +109 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +339 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +394 -0
- package/src/audio/engine.ts +112 -4
- package/src/audio/kits.ts +200 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/preview.ts +481 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/sampler.ts +157 -29
- package/src/audio/samples.ts +386 -44
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +411 -295
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +26 -4
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +298 -10
- package/src/commands/music.ts +10 -8
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +162 -2
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +1222 -30
- package/src/media/cli.ts +15 -2
- package/src/media/tools.ts +106 -1
- package/src/media/wavetable.ts +202 -0
- package/src/render.ts +22 -1
- package/src/session/naming.ts +3 -1
- package/src/tui/audition.ts +521 -0
- package/src/tui/euclid.ts +516 -0
- package/src/tui/menu.ts +1305 -244
- package/src/tui/play-chords.ts +630 -0
- package/src/tui/play-session.ts +364 -16
- package/src/tui/sketch.ts +108 -0
- package/src/web/search.ts +4 -1
- package/tui/app.ts +161 -39
- package/tui/grammar.ts +286 -0
- package/tui/highway.ts +7 -1
- package/tui/play-strip.ts +59 -14
package/src/agent/tools.ts
CHANGED
|
@@ -1,13 +1,50 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
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
|
|
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: {
|
|
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 } =
|
|
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
|
|
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: `
|
|
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)
|
package/src/agent/xcb-agent.ts
CHANGED
|
@@ -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
|
|
48
|
-
|
|
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
|
+
}
|