@hraness/dawg 0.4.1 → 0.6.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.
- package/CHANGELOG.md +99 -0
- package/DAWG.md +551 -33
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +41 -12
- package/core/expression.ts +1241 -0
- package/core/fx.ts +437 -2
- package/core/granular.ts +528 -0
- package/core/instruments.ts +281 -0
- package/core/keys.ts +386 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/resonators.ts +574 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +717 -62
- package/core/sdk/eval-child.ts +40 -3
- package/core/sdk/print.ts +614 -27
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +2824 -45
- package/core/sections.ts +2072 -0
- package/core/strings.ts +845 -0
- package/core/synth.ts +11 -1
- package/core/tempo.ts +1318 -0
- package/core/tuning.ts +1180 -0
- package/guides/audition.md +26 -0
- package/guides/automation.md +26 -0
- package/guides/chords.md +28 -0
- package/guides/effects.md +29 -0
- package/guides/faders.md +29 -0
- package/guides/files.md +28 -0
- package/guides/getting-started.md +26 -0
- package/guides/index.ts +75 -0
- package/guides/keys.md +27 -0
- package/guides/media.md +27 -0
- package/guides/mix.md +23 -0
- package/guides/music.md +14 -0
- package/guides/notes.md +26 -0
- package/guides/performance.md +26 -0
- package/guides/play.md +24 -0
- package/guides/project.md +13 -0
- package/guides/providers.md +25 -0
- package/guides/rhythm.md +26 -0
- package/guides/sessions.md +21 -0
- package/guides/sound.md +15 -0
- package/guides/sounds.md +29 -0
- package/guides/tempo.md +27 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +9 -0
- package/src/agent/brief.ts +77 -3
- package/src/agent/chord-tools.ts +9 -1
- package/src/agent/expression-tools.ts +336 -0
- package/src/agent/granular-tools.ts +138 -0
- package/src/agent/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +68 -6
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +27 -4
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +574 -16
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +9 -0
- package/src/audio/arrange.ts +489 -0
- package/src/audio/audition.ts +16 -3
- package/src/audio/click.ts +113 -1
- package/src/audio/clock.ts +71 -5
- package/src/audio/dsp/bank.ts +233 -0
- package/src/audio/dsp/fft.ts +6 -0
- package/src/audio/dsp/filters.ts +57 -0
- package/src/audio/dsp/interp.ts +112 -0
- package/src/audio/dsp/modal.ts +684 -0
- package/src/audio/dsp/onset.ts +197 -0
- package/src/audio/dsp/oversample.ts +202 -0
- package/src/audio/dsp/rng.ts +37 -0
- package/src/audio/dsp/shape.ts +81 -0
- package/src/audio/dsp/stft.ts +73 -0
- package/src/audio/dsp/window.ts +77 -0
- package/src/audio/effects/bus.ts +5 -4
- package/src/audio/effects/chain.ts +6 -1
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/dynamics.ts +2 -1
- package/src/audio/effects/filter.ts +5 -3
- package/src/audio/effects/modulation.ts +5 -1
- package/src/audio/effects/rig/cab.ts +99 -0
- package/src/audio/effects/rig/filters.ts +152 -0
- package/src/audio/effects/rig/gate.ts +39 -0
- package/src/audio/effects/rig/head.ts +487 -0
- package/src/audio/effects/rig/index.ts +170 -0
- package/src/audio/effects/rig/section.ts +78 -0
- package/src/audio/effects/rig/stomp.ts +238 -0
- package/src/audio/effects/space.ts +92 -2
- package/src/audio/engine.ts +100 -21
- package/src/audio/fit.ts +402 -0
- package/src/audio/granular.ts +664 -0
- package/src/audio/instrument-check.ts +133 -0
- package/src/audio/instruments.ts +111 -0
- package/src/audio/keys/dsp.ts +323 -0
- package/src/audio/keys/engine.ts +361 -0
- package/src/audio/keys/piano.ts +432 -0
- package/src/audio/live-worker.ts +54 -0
- package/src/audio/live.ts +263 -17
- package/src/audio/loudness.ts +596 -0
- package/src/audio/master.ts +660 -0
- package/src/audio/measure-worker.ts +45 -0
- package/src/audio/measure.ts +110 -0
- package/src/audio/player.ts +11 -6
- package/src/audio/preview.ts +76 -14
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/resonators.ts +287 -0
- package/src/audio/sampler.ts +269 -34
- package/src/audio/samples.ts +30 -7
- package/src/audio/strings/body.ts +250 -0
- package/src/audio/strings/engine.ts +369 -0
- package/src/audio/strings/loop.ts +119 -0
- package/src/audio/strings/measure.test-helpers.ts +198 -0
- package/src/audio/strings/pluck.ts +354 -0
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +147 -0
- package/src/audio/wav.ts +377 -30
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +941 -0
- package/src/commands/fit.ts +135 -0
- package/src/commands/fx.ts +31 -0
- package/src/commands/granular.ts +427 -0
- package/src/commands/help.ts +355 -27
- package/src/commands/keys.ts +353 -0
- package/src/commands/master.ts +361 -0
- package/src/commands/modal.ts +247 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/rig.ts +260 -0
- package/src/commands/sample.ts +3 -0
- package/src/commands/string.ts +175 -0
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +967 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +919 -41
- package/src/project/check.ts +13 -0
- package/src/render.ts +139 -8
- package/src/session/daemon.ts +18 -8
- package/src/session/naming.ts +8 -1
- package/src/session/rebase.ts +21 -0
- package/src/tui/arrange-menu.ts +390 -0
- package/src/tui/audition.ts +38 -5
- package/src/tui/fader.ts +409 -0
- package/src/tui/granular-menu.ts +278 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +967 -23
- package/src/tui/modal-menu.ts +118 -0
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +3 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +459 -43
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +63 -1
- package/tui/guide.ts +351 -0
- package/tui/highway.ts +87 -4
- package/tui/hits.ts +68 -0
- package/tui/input.ts +7 -0
- package/tui/keys.ts +72 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `master` prompt command: the song master chain (core/master.ts) on the
|
|
3
|
+
* summed mix, and its loudness.
|
|
4
|
+
*
|
|
5
|
+
* master show the chain and target
|
|
6
|
+
* master <unit> list a unit's parameters and presets
|
|
7
|
+
* master <unit> on|off a unit with its defaults, or off
|
|
8
|
+
* master <unit> reset a unit back to its defaults (stays on)
|
|
9
|
+
* master <unit> preset <name> load a unit preset
|
|
10
|
+
* master <unit> <param> <value> […] set parameters (turns the unit on)
|
|
11
|
+
* master target <lufs>|<name>|off integrated loudness target
|
|
12
|
+
* master <name> shorthand for `master target <name>`
|
|
13
|
+
* master measure render and measure the mix
|
|
14
|
+
* master off remove the whole master
|
|
15
|
+
*
|
|
16
|
+
* Units run in a fixed order: eq, glue, tape, width, limiter. A named target
|
|
17
|
+
* (streaming, club, loud, …) also turns the limiter on at that target's
|
|
18
|
+
* ceiling. Each command is one `setMaster` revision and one undo step.
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
LOUDNESS_TARGET_NAMES,
|
|
22
|
+
LOUDNESS_TARGETS,
|
|
23
|
+
MASTER_LIMITS,
|
|
24
|
+
MASTER_PRESETS,
|
|
25
|
+
MASTER_SPECS,
|
|
26
|
+
MASTER_UNITS,
|
|
27
|
+
describeMaster,
|
|
28
|
+
isLoudnessTargetName,
|
|
29
|
+
isMasterUnit,
|
|
30
|
+
masterDefaults,
|
|
31
|
+
normalizeMaster,
|
|
32
|
+
type LoudnessTargetName,
|
|
33
|
+
type MasterUnit,
|
|
34
|
+
type SongMaster,
|
|
35
|
+
} from "../../core/master.ts";
|
|
36
|
+
import { FxValidationError } from "../../core/params.ts";
|
|
37
|
+
import { ScoreValidationError, type TrackScore } from "../../core/score.ts";
|
|
38
|
+
import type { MixMeasurement } from "../audio/loudness.ts";
|
|
39
|
+
import type { MasterReport } from "../audio/master.ts";
|
|
40
|
+
import { parseParamValue } from "./fx.ts";
|
|
41
|
+
|
|
42
|
+
export type MasterCommand =
|
|
43
|
+
| { type: "master-list" }
|
|
44
|
+
| { type: "master-off" }
|
|
45
|
+
| { type: "master-measure" }
|
|
46
|
+
| { type: "master-show"; unit: MasterUnit }
|
|
47
|
+
| { type: "master-unit"; unit: MasterUnit; on: boolean }
|
|
48
|
+
/** Like `fx <effect> reset`: the unit's defaults, kept on. */
|
|
49
|
+
| { type: "master-reset"; unit: MasterUnit }
|
|
50
|
+
| { type: "master-preset"; unit: MasterUnit; preset: string }
|
|
51
|
+
| {
|
|
52
|
+
type: "master-set";
|
|
53
|
+
unit: MasterUnit;
|
|
54
|
+
values: Readonly<Record<string, number | string | boolean>>;
|
|
55
|
+
}
|
|
56
|
+
/** `null` clears the target; a name also sets the limiter's ceiling. */
|
|
57
|
+
| {
|
|
58
|
+
type: "master-target";
|
|
59
|
+
lufs: number | null;
|
|
60
|
+
name?: LoudnessTargetName;
|
|
61
|
+
/** The positive number typed, read as negative LUFS (`14` → -14). */
|
|
62
|
+
flipped?: number;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Platform names that mean the `streaming` target. */
|
|
66
|
+
const STREAMING_ALIASES = new Set([
|
|
67
|
+
"spotify",
|
|
68
|
+
"youtube",
|
|
69
|
+
"tidal",
|
|
70
|
+
"amazon",
|
|
71
|
+
"deezer",
|
|
72
|
+
"soundcloud",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
export function parseMasterCommand(prompt: string): MasterCommand | undefined {
|
|
76
|
+
const words = prompt
|
|
77
|
+
.trim()
|
|
78
|
+
.split(/\s+/)
|
|
79
|
+
.map((word) => word.toLowerCase());
|
|
80
|
+
if (words[0] !== "master" && words[0] !== "/master") return undefined;
|
|
81
|
+
if (prompt.length > 1_024) return undefined;
|
|
82
|
+
const rest = words.slice(1);
|
|
83
|
+
if (rest.length === 0) return { type: "master-list" };
|
|
84
|
+
const head = rest[0]!;
|
|
85
|
+
if (rest.length === 1 && (head === "off" || head === "reset"))
|
|
86
|
+
return { type: "master-off" };
|
|
87
|
+
if (rest.length === 1 && (head === "measure" || head === "meter"))
|
|
88
|
+
return { type: "master-measure" };
|
|
89
|
+
if (rest.length === 1 && isLoudnessTargetName(head)) return target(head);
|
|
90
|
+
// `master spotify` and friends: the streaming target.
|
|
91
|
+
if (rest.length === 1 && STREAMING_ALIASES.has(head))
|
|
92
|
+
return target("streaming");
|
|
93
|
+
// `master on` turns the master on the way most people mean it: the
|
|
94
|
+
// streaming target with its limiter (`master glue on` turns on one unit).
|
|
95
|
+
if (rest.length === 1 && head === "on") return target("streaming");
|
|
96
|
+
// `master -14` is `master target -14`.
|
|
97
|
+
if (rest.length === 1 && /^[-+]?\d+(?:\.\d+)?$/.test(head))
|
|
98
|
+
return target(head);
|
|
99
|
+
if (head === "target" || head === "lufs" || head === "normalize")
|
|
100
|
+
return rest.length === 2 ? target(rest[1]!) : undefined;
|
|
101
|
+
if (!isMasterUnit(head)) return undefined;
|
|
102
|
+
const unit = head;
|
|
103
|
+
if (rest.length === 1) return { type: "master-show", unit };
|
|
104
|
+
if (rest.length === 2 && rest[1] === "reset")
|
|
105
|
+
return { type: "master-reset", unit };
|
|
106
|
+
if (rest.length === 2 && (rest[1] === "on" || rest[1] === "off"))
|
|
107
|
+
return { type: "master-unit", unit, on: rest[1] === "on" };
|
|
108
|
+
// An unknown preset name still parses, so the reply can list the real ones.
|
|
109
|
+
if (rest[1] === "preset")
|
|
110
|
+
return rest.length === 3
|
|
111
|
+
? { type: "master-preset", unit, preset: rest[2]! }
|
|
112
|
+
: undefined;
|
|
113
|
+
if (rest.length === 2 && isMasterPreset(unit, rest[1]!))
|
|
114
|
+
return { type: "master-preset", unit, preset: rest[1]! };
|
|
115
|
+
const pairs = rest.slice(1);
|
|
116
|
+
if (pairs.length % 2 !== 0) return undefined;
|
|
117
|
+
const values: Record<string, number | string | boolean> = {};
|
|
118
|
+
const params = MASTER_SPECS[unit].params;
|
|
119
|
+
for (let index = 0; index < pairs.length; index += 2) {
|
|
120
|
+
const name = pairs[index]!;
|
|
121
|
+
const spec = Object.prototype.hasOwnProperty.call(params, name)
|
|
122
|
+
? params[name]
|
|
123
|
+
: undefined;
|
|
124
|
+
if (!spec) return undefined;
|
|
125
|
+
const raw = pairs[index + 1]!;
|
|
126
|
+
// An out-of-range number still parses, so validation can name the range.
|
|
127
|
+
const value =
|
|
128
|
+
parseParamValue(spec, raw) ??
|
|
129
|
+
(/^[-+]?\d+(?:\.\d+)?$/.test(raw) ? Number(raw) : undefined);
|
|
130
|
+
if (value === undefined) return undefined;
|
|
131
|
+
values[name] = value;
|
|
132
|
+
}
|
|
133
|
+
return { type: "master-set", unit, values };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Own preset names only: `constructor` and friends are not presets. */
|
|
137
|
+
export function isMasterPreset(unit: MasterUnit, name: string): boolean {
|
|
138
|
+
return Object.hasOwn(MASTER_PRESETS[unit], name);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function target(word: string): MasterCommand | undefined {
|
|
142
|
+
if (word === "off" || word === "none")
|
|
143
|
+
return { type: "master-target", lufs: null };
|
|
144
|
+
if (isLoudnessTargetName(word))
|
|
145
|
+
return {
|
|
146
|
+
type: "master-target",
|
|
147
|
+
lufs: LOUDNESS_TARGETS[word].lufs,
|
|
148
|
+
name: word,
|
|
149
|
+
};
|
|
150
|
+
if (!/^[-+]?\d+(?:\.\d+)?$/.test(word)) return undefined;
|
|
151
|
+
// `target 14` means -14 LUFS: loudness targets are always negative, and
|
|
152
|
+
// the reply says so. Out of range still parses, so the reply names it.
|
|
153
|
+
const value = Number(word);
|
|
154
|
+
return value > 0
|
|
155
|
+
? { type: "master-target", lufs: -value, flipped: value }
|
|
156
|
+
: { type: "master-target", lufs: -Math.abs(value) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export type MasterResult = Readonly<{
|
|
160
|
+
ok: boolean;
|
|
161
|
+
message: string;
|
|
162
|
+
next?: TrackScore;
|
|
163
|
+
kind?: string;
|
|
164
|
+
payload?: Record<string, unknown>;
|
|
165
|
+
}>;
|
|
166
|
+
|
|
167
|
+
/** The master after one command, before validation. */
|
|
168
|
+
export function nextMaster(
|
|
169
|
+
master: SongMaster | undefined,
|
|
170
|
+
command: MasterCommand,
|
|
171
|
+
): Record<string, unknown> | undefined {
|
|
172
|
+
const draft: Record<string, unknown> = { ...master };
|
|
173
|
+
switch (command.type) {
|
|
174
|
+
case "master-list":
|
|
175
|
+
case "master-measure":
|
|
176
|
+
case "master-show":
|
|
177
|
+
return master;
|
|
178
|
+
case "master-reset":
|
|
179
|
+
draft[command.unit] = masterDefaults(command.unit);
|
|
180
|
+
return draft;
|
|
181
|
+
case "master-off":
|
|
182
|
+
return undefined;
|
|
183
|
+
case "master-unit":
|
|
184
|
+
if (command.on) draft[command.unit] ??= masterDefaults(command.unit);
|
|
185
|
+
else delete draft[command.unit];
|
|
186
|
+
return draft;
|
|
187
|
+
case "master-preset":
|
|
188
|
+
if (!isMasterPreset(command.unit, command.preset)) return master;
|
|
189
|
+
draft[command.unit] = {
|
|
190
|
+
...masterDefaults(command.unit),
|
|
191
|
+
...MASTER_PRESETS[command.unit][command.preset],
|
|
192
|
+
};
|
|
193
|
+
return draft;
|
|
194
|
+
case "master-set":
|
|
195
|
+
draft[command.unit] = {
|
|
196
|
+
...(master?.[command.unit] ?? masterDefaults(command.unit)),
|
|
197
|
+
...command.values,
|
|
198
|
+
};
|
|
199
|
+
return draft;
|
|
200
|
+
case "master-target":
|
|
201
|
+
if (command.lufs === null) delete draft.target;
|
|
202
|
+
else draft.target = command.lufs;
|
|
203
|
+
if (command.name) {
|
|
204
|
+
const named: { ceiling: number; limiter?: string } =
|
|
205
|
+
LOUDNESS_TARGETS[command.name];
|
|
206
|
+
draft.limiter = {
|
|
207
|
+
...(master?.limiter ?? masterDefaults("limiter")),
|
|
208
|
+
...(named.limiter ? MASTER_PRESETS.limiter[named.limiter] : {}),
|
|
209
|
+
ceiling: named.ceiling,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return draft;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function applyMasterCommand(
|
|
217
|
+
score: TrackScore,
|
|
218
|
+
command: MasterCommand,
|
|
219
|
+
): MasterResult {
|
|
220
|
+
if (command.type === "master-list")
|
|
221
|
+
return { ok: true, message: masterSummary(score.master) };
|
|
222
|
+
if (command.type === "master-measure")
|
|
223
|
+
return { ok: false, message: "master measure needs a render" };
|
|
224
|
+
if (command.type === "master-show")
|
|
225
|
+
return { ok: true, message: unitSummary(command.unit, score.master) };
|
|
226
|
+
if (
|
|
227
|
+
command.type === "master-preset" &&
|
|
228
|
+
!isMasterPreset(command.unit, command.preset)
|
|
229
|
+
)
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
message: `master ${command.unit} · no preset ${command.preset} · presets ${Object.keys(MASTER_PRESETS[command.unit]).join(" ")}`,
|
|
233
|
+
};
|
|
234
|
+
let next: TrackScore;
|
|
235
|
+
try {
|
|
236
|
+
const master = normalizeMaster(nextMaster(score.master, command)) ?? null;
|
|
237
|
+
next = score.withMaster(master);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (
|
|
240
|
+
error instanceof ScoreValidationError ||
|
|
241
|
+
error instanceof FxValidationError
|
|
242
|
+
)
|
|
243
|
+
return {
|
|
244
|
+
ok: false,
|
|
245
|
+
message: `master · ${error.message.replace(/^master\s+/, "")}`,
|
|
246
|
+
};
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
if (sameMaster(score.master, next.master))
|
|
250
|
+
return { ok: true, message: masterSummary(score.master) };
|
|
251
|
+
const parts = [`master · ${describeMaster(next.master)}`];
|
|
252
|
+
if (command.type === "master-target" && command.flipped !== undefined)
|
|
253
|
+
parts.push(`read ${command.flipped} as ${-command.flipped} LUFS`);
|
|
254
|
+
if (
|
|
255
|
+
next.master?.target !== undefined &&
|
|
256
|
+
!next.master.limiter &&
|
|
257
|
+
next.master.target > -16
|
|
258
|
+
)
|
|
259
|
+
parts.push(
|
|
260
|
+
`no limiter: the gain stops at ${MASTER_LIMITS.safeCeiling} dBTP · master limiter on pushes harder`,
|
|
261
|
+
);
|
|
262
|
+
return {
|
|
263
|
+
ok: true,
|
|
264
|
+
message: parts.join(" · "),
|
|
265
|
+
next,
|
|
266
|
+
kind: "score.master",
|
|
267
|
+
payload: { master: next.master ?? null },
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function sameMaster(a: SongMaster | undefined, b: SongMaster | undefined) {
|
|
272
|
+
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** `master · off · units eq glue …` or the chain with its target. */
|
|
276
|
+
export function masterSummary(master: SongMaster | undefined): string {
|
|
277
|
+
if (!master)
|
|
278
|
+
return `master · off · units ${MASTER_UNITS.join(" ")} · targets ${LOUDNESS_TARGET_NAMES.join(" ")}`;
|
|
279
|
+
return `master · ${describeMaster(master)}`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* One unit for `master <unit>`: on or off, then every parameter with its
|
|
284
|
+
* current (or default) value, unit and range, then the presets.
|
|
285
|
+
*/
|
|
286
|
+
export function unitSummary(unit: MasterUnit, master?: SongMaster): string {
|
|
287
|
+
const values = master?.[unit];
|
|
288
|
+
const params = Object.entries(MASTER_SPECS[unit].params).map(
|
|
289
|
+
([name, spec]) => {
|
|
290
|
+
const value = values?.[name] ?? spec.default;
|
|
291
|
+
if (spec.kind === "number")
|
|
292
|
+
return `${name} ${formatValue(value)}${spec.unit ? ` ${spec.unit}` : ""} (${formatValue(spec.min)}..${formatValue(spec.max)})`;
|
|
293
|
+
if (spec.kind === "enum")
|
|
294
|
+
return `${name} ${String(value)} (${spec.values.join("|")})`;
|
|
295
|
+
return `${name} ${value ? "on" : "off"} (on|off)`;
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
return [
|
|
299
|
+
`master ${unit} · ${values ? "on" : "off"}`,
|
|
300
|
+
...params,
|
|
301
|
+
`presets ${Object.keys(MASTER_PRESETS[unit]).join(" ")}`,
|
|
302
|
+
].join(" · ");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function formatValue(value: number | string | boolean): string {
|
|
306
|
+
return typeof value === "number"
|
|
307
|
+
? String(Math.round(value * 100) / 100)
|
|
308
|
+
: String(value);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const fixed = (value: number, digits = 1) =>
|
|
312
|
+
Number.isFinite(value) ? value.toFixed(digits) : "-∞";
|
|
313
|
+
|
|
314
|
+
/** `-14.0 LUFS · -1.0 dBTP`: the compact meter. */
|
|
315
|
+
export function loudnessLine(
|
|
316
|
+
loudness: Readonly<{
|
|
317
|
+
integrated: number;
|
|
318
|
+
truePeak: number;
|
|
319
|
+
target?: number;
|
|
320
|
+
reached?: boolean;
|
|
321
|
+
}>,
|
|
322
|
+
): string {
|
|
323
|
+
const line = `${fixed(loudness.integrated)} LUFS · ${fixed(loudness.truePeak)} dBTP`;
|
|
324
|
+
if (loudness.target === undefined) return line;
|
|
325
|
+
return loudness.reached
|
|
326
|
+
? `${line} · target ${fixed(loudness.target)} reached`
|
|
327
|
+
: `${line} · target ${fixed(loudness.target)} missed`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* One line for `master measure` and `dawg render`: loudness, range, peaks,
|
|
332
|
+
* the target outcome, then balance and stereo.
|
|
333
|
+
*/
|
|
334
|
+
export function measurementLine(
|
|
335
|
+
mix: MixMeasurement,
|
|
336
|
+
report?: MasterReport,
|
|
337
|
+
): string {
|
|
338
|
+
const { loudness } = mix;
|
|
339
|
+
const parts = [
|
|
340
|
+
`${fixed(loudness.integrated)} LUFS`,
|
|
341
|
+
`momentary max ${fixed(loudness.momentaryMax)}`,
|
|
342
|
+
`short-term max ${fixed(loudness.shortTermMax)}`,
|
|
343
|
+
`LRA ${fixed(loudness.range)} LU`,
|
|
344
|
+
`${fixed(loudness.truePeak)} dBTP`,
|
|
345
|
+
`PLR ${fixed(mix.plr)}`,
|
|
346
|
+
];
|
|
347
|
+
if (report?.target !== undefined && !Number.isFinite(report.integrated))
|
|
348
|
+
parts.push(`target ${fixed(report.target)} not applied · silent`);
|
|
349
|
+
else if (report?.target !== undefined)
|
|
350
|
+
parts.push(
|
|
351
|
+
report.reached
|
|
352
|
+
? `target ${fixed(report.target)} reached`
|
|
353
|
+
: `target ${fixed(report.target)} missed by ${fixed(Math.abs(report.target - report.integrated))} LU${report.integrated < report.target ? " (add master tape or a faster limiter release)" : ""}`,
|
|
354
|
+
);
|
|
355
|
+
const bands = Object.entries(mix.bands)
|
|
356
|
+
.map(([name, db]) => `${name} ${fixed(db, 0)}`)
|
|
357
|
+
.join(" ");
|
|
358
|
+
parts.push(`bands ${bands} dB`);
|
|
359
|
+
parts.push(`correlation ${fixed(mix.correlation, 2)}`);
|
|
360
|
+
return parts.join(" · ");
|
|
361
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `modal` prompt command (0.6): modal percussion on the focused track
|
|
3
|
+
* (core/resonators.ts). The menu and the `set_modal` agent tool build the
|
|
4
|
+
* same edits.
|
|
5
|
+
*
|
|
6
|
+
* modal show the track's preset and overrides
|
|
7
|
+
* modal <preset> play a preset: marimba vibes xylophone
|
|
8
|
+
* glock celesta chimes kalimba mbira
|
|
9
|
+
* steelpan bowl gong timpani (and the
|
|
10
|
+
* aliases vibraphone glockenspiel
|
|
11
|
+
* tubular thumbpiano gongageng)
|
|
12
|
+
* modal preset <name> the same, spelled out
|
|
13
|
+
* modal mallet <yarn|cord|rubber|plastic|brass>
|
|
14
|
+
* modal <param> <value> [<param> <value>…] hardness 0.8, ring 3 …
|
|
15
|
+
* modal <param> off back to the preset's value
|
|
16
|
+
* modal reset clear overrides (keep the preset)
|
|
17
|
+
* modal off back to the legacy marimba voice
|
|
18
|
+
* modal list | presets modal the presets with one-line characters
|
|
19
|
+
*
|
|
20
|
+
* Any edit turns the track into a modal track (`instrument "modal"`); the
|
|
21
|
+
* legacy `marimba` tone is untouched until then. One `updateTrack` revision
|
|
22
|
+
* and one undo step per command.
|
|
23
|
+
*/
|
|
24
|
+
import { FxValidationError } from "../../core/params.ts";
|
|
25
|
+
import {
|
|
26
|
+
DEFAULT_MODAL_PRESET,
|
|
27
|
+
MODAL_INSTRUMENT,
|
|
28
|
+
MODAL_PARAMS,
|
|
29
|
+
MODAL_PRESETS,
|
|
30
|
+
MODAL_PRESET_NAMES,
|
|
31
|
+
MODAL_SIMPLE,
|
|
32
|
+
modalParamName,
|
|
33
|
+
modalPresetFor,
|
|
34
|
+
normalizeModal,
|
|
35
|
+
type TrackModal,
|
|
36
|
+
} from "../../core/resonators.ts";
|
|
37
|
+
import {
|
|
38
|
+
ScoreValidationError,
|
|
39
|
+
updateTrack,
|
|
40
|
+
type TrackScore,
|
|
41
|
+
} from "../../core/score.ts";
|
|
42
|
+
import { parseParamValue } from "./fx.ts";
|
|
43
|
+
|
|
44
|
+
export type ModalCommand =
|
|
45
|
+
| { type: "modal-show" }
|
|
46
|
+
| { type: "modal-list" }
|
|
47
|
+
| { type: "modal-reset" }
|
|
48
|
+
| { type: "modal-off" }
|
|
49
|
+
| { type: "modal-preset"; preset: string }
|
|
50
|
+
| { type: "modal-usage"; message: string }
|
|
51
|
+
| {
|
|
52
|
+
type: "modal-set";
|
|
53
|
+
/** `null` returns a parameter to the preset's value. */
|
|
54
|
+
values: Readonly<Record<string, number | string | null>>;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** The voice `modal off` returns a track to: the legacy marimba tone. */
|
|
58
|
+
export const MODAL_OFF_INSTRUMENT = "marimba";
|
|
59
|
+
|
|
60
|
+
export const MODAL_USAGE =
|
|
61
|
+
"modal <preset> | modal <body> | modal <param> <value> | modal mallet <name> | modal reset | modal off | modal presets";
|
|
62
|
+
|
|
63
|
+
function rangeOf(name: string): string {
|
|
64
|
+
const spec = MODAL_PARAMS[name]!;
|
|
65
|
+
if (spec.kind === "number") return `${name} is ${spec.min}..${spec.max}`;
|
|
66
|
+
if (spec.kind === "enum") return `${name} is one of ${spec.values.join(" ")}`;
|
|
67
|
+
return name;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseModalCommand(prompt: string): ModalCommand | undefined {
|
|
71
|
+
const words = prompt.trim().toLowerCase().split(/\s+/);
|
|
72
|
+
if (words[0] === "presets" && words[1] === "modal" && words.length === 2)
|
|
73
|
+
return { type: "modal-list" };
|
|
74
|
+
if (words[0] !== "modal") return undefined;
|
|
75
|
+
if (words.length === 1) return { type: "modal-show" };
|
|
76
|
+
if (prompt.length > 512) return undefined;
|
|
77
|
+
const rest = words.slice(1);
|
|
78
|
+
if (rest.length === 1 && rest[0] === "reset") return { type: "modal-reset" };
|
|
79
|
+
if (rest.length === 1 && rest[0] === "off") return { type: "modal-off" };
|
|
80
|
+
if (rest.length === 1 && (rest[0] === "list" || rest[0] === "presets"))
|
|
81
|
+
return { type: "modal-list" };
|
|
82
|
+
if (rest[0] === "preset") {
|
|
83
|
+
const preset = rest.length === 2 ? modalPresetFor(rest[1]!) : undefined;
|
|
84
|
+
return preset
|
|
85
|
+
? { type: "modal-preset", preset }
|
|
86
|
+
: {
|
|
87
|
+
type: "modal-usage",
|
|
88
|
+
message: `modal preset is one of ${MODAL_PRESET_NAMES.join(" ")}`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (rest.length === 1) {
|
|
92
|
+
const preset = modalPresetFor(rest[0]!);
|
|
93
|
+
if (preset) return { type: "modal-preset", preset };
|
|
94
|
+
// A body word (saron, kempul, bonang, gender ...) sets the body: the
|
|
95
|
+
// gamelan presets come later, the bodies already ship.
|
|
96
|
+
const body = MODAL_PARAMS.body;
|
|
97
|
+
if (body?.kind === "enum" && body.values.includes(rest[0]!))
|
|
98
|
+
return { type: "modal-set", values: { body: rest[0]! } };
|
|
99
|
+
return { type: "modal-usage", message: MODAL_USAGE };
|
|
100
|
+
}
|
|
101
|
+
if (rest.length % 2 !== 0)
|
|
102
|
+
return { type: "modal-usage", message: MODAL_USAGE };
|
|
103
|
+
const values: Record<string, number | string | null> = {};
|
|
104
|
+
for (let index = 0; index < rest.length; index += 2) {
|
|
105
|
+
const name = modalParamName(rest[index]!);
|
|
106
|
+
if (!name)
|
|
107
|
+
return {
|
|
108
|
+
type: "modal-usage",
|
|
109
|
+
message: `modal has no parameter ${rest[index]!.slice(0, 24)} · ${MODAL_SIMPLE.join(" ")} …`,
|
|
110
|
+
};
|
|
111
|
+
const word = rest[index + 1]!;
|
|
112
|
+
if (word === "off" || word === "unset") {
|
|
113
|
+
values[name] = null;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const value = parseParamValue(MODAL_PARAMS[name]!, word);
|
|
117
|
+
if (value === undefined || typeof value === "boolean")
|
|
118
|
+
return { type: "modal-usage", message: rangeOf(name) };
|
|
119
|
+
values[name] = value;
|
|
120
|
+
}
|
|
121
|
+
return { type: "modal-set", values };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `preset vibes · hardness 0.6 · ring 3`, overrides in canonical order. */
|
|
125
|
+
export function describeModal(modal: TrackModal | undefined): string {
|
|
126
|
+
if (!modal) return "not a modal track";
|
|
127
|
+
const preset = modal.preset ?? DEFAULT_MODAL_PRESET;
|
|
128
|
+
const overrides = Object.entries(modal)
|
|
129
|
+
.filter(([key]) => key !== "preset")
|
|
130
|
+
.map(([key, value]) => `${key} ${value}`);
|
|
131
|
+
return [`preset ${preset}`, ...overrides].join(" · ");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** One line per preset, for `modal list` and the agent brief. */
|
|
135
|
+
export function modalListLines(): string[] {
|
|
136
|
+
return MODAL_PRESET_NAMES.map(
|
|
137
|
+
(name) => `${name.padEnd(10)} ${MODAL_PRESETS[name].doc}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export type ModalResult = Readonly<{
|
|
142
|
+
ok: boolean;
|
|
143
|
+
message: string;
|
|
144
|
+
next?: TrackScore;
|
|
145
|
+
kind?: string;
|
|
146
|
+
payload?: Record<string, unknown>;
|
|
147
|
+
}>;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The modal field a command leaves on a track (pure; the agent tool shares
|
|
151
|
+
* it). A preset switch keeps the overrides; `reset` keeps the preset.
|
|
152
|
+
* `mallet` and `hardness` are one choice, so the one written last wins.
|
|
153
|
+
*/
|
|
154
|
+
export function nextModal(
|
|
155
|
+
current: TrackModal | undefined,
|
|
156
|
+
command: Extract<
|
|
157
|
+
ModalCommand,
|
|
158
|
+
{ type: "modal-reset" | "modal-preset" | "modal-set" }
|
|
159
|
+
>,
|
|
160
|
+
): TrackModal {
|
|
161
|
+
const base: Record<string, unknown> = { ...current };
|
|
162
|
+
if (command.type === "modal-reset")
|
|
163
|
+
return normalizeModal(current?.preset ? { preset: current.preset } : {})!;
|
|
164
|
+
if (command.type === "modal-preset") base.preset = command.preset;
|
|
165
|
+
else
|
|
166
|
+
for (const [key, value] of Object.entries(command.values)) {
|
|
167
|
+
if (value === null) delete base[key];
|
|
168
|
+
else {
|
|
169
|
+
base[key] = value;
|
|
170
|
+
if (key === "mallet" && !("hardness" in command.values))
|
|
171
|
+
delete base.hardness;
|
|
172
|
+
if (key === "hardness" && !("mallet" in command.values))
|
|
173
|
+
delete base.mallet;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return normalizeModal(base)!;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function applyModalCommand(
|
|
180
|
+
score: TrackScore,
|
|
181
|
+
trackId: string,
|
|
182
|
+
command: ModalCommand,
|
|
183
|
+
): ModalResult {
|
|
184
|
+
if (command.type === "modal-usage")
|
|
185
|
+
return { ok: false, message: `modal · ${command.message}` };
|
|
186
|
+
if (command.type === "modal-list")
|
|
187
|
+
return {
|
|
188
|
+
ok: true,
|
|
189
|
+
message: `modal presets · ${MODAL_PRESET_NAMES.join(" ")}`,
|
|
190
|
+
};
|
|
191
|
+
const track = score.tracks.find((candidate) => candidate.id === trackId);
|
|
192
|
+
if (!track) return { ok: false, message: `no track · ${trackId}` };
|
|
193
|
+
if (command.type === "modal-show")
|
|
194
|
+
return {
|
|
195
|
+
ok: true,
|
|
196
|
+
message:
|
|
197
|
+
track.instrument === MODAL_INSTRUMENT && track.modal
|
|
198
|
+
? `modal · ${describeModal(track.modal)}`
|
|
199
|
+
: `modal · ${trackId} plays ${track.instrument} · modal <preset> to switch (${MODAL_PRESET_NAMES.join(" ")})`,
|
|
200
|
+
};
|
|
201
|
+
if (track.sampler || track.instrument === "kit")
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
message: `modal · ${trackId} is a ${track.sampler ? "sampler" : "drum"} track`,
|
|
205
|
+
};
|
|
206
|
+
const current =
|
|
207
|
+
track.instrument === MODAL_INSTRUMENT ? track.modal : undefined;
|
|
208
|
+
if (command.type === "modal-off") {
|
|
209
|
+
if (track.instrument !== MODAL_INSTRUMENT)
|
|
210
|
+
return { ok: true, message: "modal · already off" };
|
|
211
|
+
// Like `string off`: leave the engine for the legacy voice.
|
|
212
|
+
const next = updateTrack(score, trackId, {
|
|
213
|
+
instrument: MODAL_OFF_INSTRUMENT,
|
|
214
|
+
modal: null,
|
|
215
|
+
});
|
|
216
|
+
return {
|
|
217
|
+
ok: true,
|
|
218
|
+
message: `modal · off (${MODAL_OFF_INSTRUMENT}) · modal ${current?.preset ?? DEFAULT_MODAL_PRESET} turns it back on`,
|
|
219
|
+
next,
|
|
220
|
+
kind: "score.modal",
|
|
221
|
+
payload: { trackId, instrument: MODAL_OFF_INSTRUMENT, modal: null },
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
let modal: TrackModal;
|
|
225
|
+
let next: TrackScore;
|
|
226
|
+
try {
|
|
227
|
+
modal = nextModal(current, command);
|
|
228
|
+
next = updateTrack(score, trackId, {
|
|
229
|
+
instrument: MODAL_INSTRUMENT,
|
|
230
|
+
modal,
|
|
231
|
+
});
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (
|
|
234
|
+
error instanceof ScoreValidationError ||
|
|
235
|
+
error instanceof FxValidationError
|
|
236
|
+
)
|
|
237
|
+
return { ok: false, message: `modal · ${error.message}` };
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
ok: true,
|
|
242
|
+
message: `modal · ${describeModal(modal)}`,
|
|
243
|
+
next,
|
|
244
|
+
kind: "score.modal",
|
|
245
|
+
payload: { trackId, instrument: MODAL_INSTRUMENT, modal },
|
|
246
|
+
};
|
|
247
|
+
}
|
package/src/commands/music.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
type NoteInput,
|
|
34
34
|
type TrackScore,
|
|
35
35
|
} from "../../core/score.ts";
|
|
36
|
+
import { loopTicksOf } from "../../core/tempo.ts";
|
|
36
37
|
import {
|
|
37
38
|
drumVoiceForPitch,
|
|
38
39
|
drumVoicePitch,
|
|
@@ -383,7 +384,11 @@ export function applyMusicCommand(
|
|
|
383
384
|
const beats =
|
|
384
385
|
command.type === "drum-hits"
|
|
385
386
|
? command.beats
|
|
386
|
-
: everyBeats(
|
|
387
|
+
: everyBeats(
|
|
388
|
+
command.from,
|
|
389
|
+
command.step,
|
|
390
|
+
loopTicksOf(score) / score.ticksPerBeat,
|
|
391
|
+
);
|
|
387
392
|
if (beats.length > MAX_PATTERN_HITS)
|
|
388
393
|
return { ok: false, message: `pattern exceeds ${MAX_PATTERN_HITS} hits` };
|
|
389
394
|
const pitch = drumVoicePitch(command.voice);
|