@hraness/dawg 0.4.1 → 0.5.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 (113) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/DAWG.md +293 -31
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +37 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/loop.ts +23 -0
  8. package/core/master.ts +455 -0
  9. package/core/midi.ts +452 -0
  10. package/core/rhythm.ts +7 -2
  11. package/core/score.ts +536 -56
  12. package/core/sdk/eval-child.ts +38 -3
  13. package/core/sdk/print.ts +445 -14
  14. package/core/sdk/v1.ts +1709 -23
  15. package/core/sections.ts +2046 -0
  16. package/core/synth.ts +11 -1
  17. package/core/tempo.ts +1318 -0
  18. package/core/tuning.ts +1180 -0
  19. package/guides/audition.md +26 -0
  20. package/guides/automation.md +26 -0
  21. package/guides/chords.md +28 -0
  22. package/guides/effects.md +27 -0
  23. package/guides/faders.md +29 -0
  24. package/guides/files.md +28 -0
  25. package/guides/getting-started.md +26 -0
  26. package/guides/index.ts +75 -0
  27. package/guides/keys.md +27 -0
  28. package/guides/media.md +27 -0
  29. package/guides/mix.md +23 -0
  30. package/guides/music.md +14 -0
  31. package/guides/notes.md +26 -0
  32. package/guides/performance.md +26 -0
  33. package/guides/play.md +24 -0
  34. package/guides/project.md +13 -0
  35. package/guides/providers.md +25 -0
  36. package/guides/rhythm.md +26 -0
  37. package/guides/sessions.md +21 -0
  38. package/guides/sound.md +15 -0
  39. package/guides/sounds.md +25 -0
  40. package/guides/tempo.md +26 -0
  41. package/guides/tracks.md +25 -0
  42. package/guides/web-search.md +21 -0
  43. package/package.json +3 -1
  44. package/src/agent/agent.ts +2 -0
  45. package/src/agent/brief.ts +77 -3
  46. package/src/agent/chord-tools.ts +9 -1
  47. package/src/agent/expression-tools.ts +336 -0
  48. package/src/agent/master-tools.ts +299 -0
  49. package/src/agent/models.ts +4 -4
  50. package/src/agent/ops.ts +29 -4
  51. package/src/agent/planner.ts +45 -2
  52. package/src/agent/preview-tool.ts +21 -3
  53. package/src/agent/section-tools.ts +411 -0
  54. package/src/agent/time-tools.ts +290 -0
  55. package/src/agent/tools.ts +36 -2
  56. package/src/agent/tuning-tools.ts +301 -0
  57. package/src/agent/xcb-agent.ts +2 -0
  58. package/src/audio/arrange.ts +471 -0
  59. package/src/audio/audition.ts +16 -3
  60. package/src/audio/click.ts +113 -1
  61. package/src/audio/clock.ts +71 -5
  62. package/src/audio/effects/bus.ts +5 -4
  63. package/src/audio/effects/common.ts +30 -1
  64. package/src/audio/effects/dynamics.ts +2 -1
  65. package/src/audio/effects/filter.ts +5 -3
  66. package/src/audio/effects/modulation.ts +5 -1
  67. package/src/audio/effects/space.ts +92 -2
  68. package/src/audio/engine.ts +90 -20
  69. package/src/audio/live.ts +33 -6
  70. package/src/audio/loudness.ts +551 -0
  71. package/src/audio/master.ts +660 -0
  72. package/src/audio/measure-worker.ts +45 -0
  73. package/src/audio/measure.ts +110 -0
  74. package/src/audio/player.ts +11 -6
  75. package/src/audio/preview.ts +74 -14
  76. package/src/audio/render-worker.ts +6 -1
  77. package/src/audio/renderer.ts +7 -1
  78. package/src/audio/sampler.ts +108 -19
  79. package/src/audio/synth/voice.ts +60 -13
  80. package/src/audio/synth/zzfx.ts +10 -4
  81. package/src/audio/warp.ts +86 -0
  82. package/src/audio/wav.ts +301 -26
  83. package/src/commands/arrange.ts +949 -0
  84. package/src/commands/expression.ts +934 -0
  85. package/src/commands/help.ts +281 -24
  86. package/src/commands/master.ts +361 -0
  87. package/src/commands/music.ts +6 -1
  88. package/src/commands/synth.ts +11 -1
  89. package/src/commands/time.ts +964 -0
  90. package/src/commands/tuning.ts +490 -0
  91. package/src/main.ts +709 -33
  92. package/src/render.ts +109 -7
  93. package/src/session/daemon.ts +18 -8
  94. package/src/session/naming.ts +8 -1
  95. package/src/session/rebase.ts +21 -0
  96. package/src/tui/arrange-menu.ts +390 -0
  97. package/src/tui/audition.ts +38 -5
  98. package/src/tui/fader.ts +409 -0
  99. package/src/tui/menu-time.ts +401 -0
  100. package/src/tui/menu.ts +635 -14
  101. package/src/tui/performance-menu.ts +235 -0
  102. package/src/tui/play-chords.ts +3 -1
  103. package/src/tui/play-mode.ts +150 -6
  104. package/src/tui/play-session.ts +414 -41
  105. package/tui/app.ts +262 -13
  106. package/tui/arrange-strip.ts +174 -0
  107. package/tui/drawer.ts +478 -0
  108. package/tui/grammar.ts +63 -1
  109. package/tui/guide.ts +351 -0
  110. package/tui/highway.ts +87 -4
  111. package/tui/hits.ts +68 -0
  112. package/tui/input.ts +7 -0
  113. 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
+ }
@@ -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(command.from, command.step, score.bars * score.beatsPerBar);
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);
@@ -14,6 +14,7 @@
14
14
  * 0.01 0.2 0.5 0.3` (Strudel's `adsr` shorthand). Each command is one
15
15
  * `updateTrack` revision and one undo step.
16
16
  */
17
+ import { DEFAULT_GLIDE_SECONDS } from "../../core/expression.ts";
17
18
  import { FxValidationError } from "../../core/params.ts";
18
19
  import {
19
20
  ScoreValidationError,
@@ -199,9 +200,18 @@ export function applySynthCommand(
199
200
  let next: TrackScore;
200
201
  try {
201
202
  const normalized = normalizeSynth(synth);
203
+ // The acid preset is a TB-303 line: it slides between overlapping
204
+ // notes unless the track already has a glide.
205
+ const slide =
206
+ command.type === "synth-preset" &&
207
+ command.preset === "acid" &&
208
+ !track.glide
209
+ ? { glide: { time: DEFAULT_GLIDE_SECONDS, mode: "legato" as const } }
210
+ : {};
202
211
  next = updateTrack(score, trackId, {
203
212
  instrument,
204
213
  synth: normalized ?? null,
214
+ ...slide,
205
215
  });
206
216
  } catch (error) {
207
217
  if (
@@ -216,7 +226,7 @@ export function applySynthCommand(
216
226
  ok: true,
217
227
  message:
218
228
  command.type === "synth-preset"
219
- ? `synth · preset ${command.preset} · ${stored?.instrument} · ${describeSynth(stored?.synth)}`
229
+ ? `synth · preset ${command.preset} · ${stored?.instrument} · ${describeSynth(stored?.synth)}${stored?.glide && !track.glide ? ` · glide ${Math.round(stored.glide.time * 1000)}ms ${stored.glide.mode}` : ""}`
220
230
  : `synth · ${describeSynth(stored?.synth)}`,
221
231
  next,
222
232
  kind: "score.synth",