@hraness/dawg 0.4.0 → 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 (116) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/DAWG.md +300 -35
  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/chain.ts +12 -2
  64. package/src/audio/effects/common.ts +30 -1
  65. package/src/audio/effects/convolution.ts +43 -5
  66. package/src/audio/effects/dynamics.ts +2 -1
  67. package/src/audio/effects/filter.ts +5 -3
  68. package/src/audio/effects/modulation.ts +5 -1
  69. package/src/audio/effects/space.ts +197 -50
  70. package/src/audio/engine.ts +175 -20
  71. package/src/audio/live.ts +33 -6
  72. package/src/audio/loudness.ts +551 -0
  73. package/src/audio/master.ts +660 -0
  74. package/src/audio/measure-worker.ts +45 -0
  75. package/src/audio/measure.ts +110 -0
  76. package/src/audio/player.ts +11 -6
  77. package/src/audio/preview.ts +87 -16
  78. package/src/audio/render-worker.ts +6 -1
  79. package/src/audio/renderer.ts +7 -1
  80. package/src/audio/sampler.ts +108 -19
  81. package/src/audio/synth/voice.ts +60 -13
  82. package/src/audio/synth/zzfx.ts +10 -4
  83. package/src/audio/warp.ts +86 -0
  84. package/src/audio/wav.ts +448 -59
  85. package/src/commands/arrange.ts +949 -0
  86. package/src/commands/expression.ts +934 -0
  87. package/src/commands/help.ts +281 -24
  88. package/src/commands/master.ts +361 -0
  89. package/src/commands/music.ts +6 -1
  90. package/src/commands/synth.ts +11 -1
  91. package/src/commands/time.ts +964 -0
  92. package/src/commands/tuning.ts +490 -0
  93. package/src/main.ts +826 -47
  94. package/src/render.ts +109 -7
  95. package/src/session/daemon.ts +18 -8
  96. package/src/session/naming.ts +8 -1
  97. package/src/session/rebase.ts +21 -0
  98. package/src/tui/arrange-menu.ts +390 -0
  99. package/src/tui/audition.ts +58 -5
  100. package/src/tui/euclid.ts +50 -6
  101. package/src/tui/fader.ts +409 -0
  102. package/src/tui/menu-time.ts +401 -0
  103. package/src/tui/menu.ts +656 -16
  104. package/src/tui/performance-menu.ts +235 -0
  105. package/src/tui/play-chords.ts +95 -1
  106. package/src/tui/play-mode.ts +150 -6
  107. package/src/tui/play-session.ts +414 -41
  108. package/tui/app.ts +262 -13
  109. package/tui/arrange-strip.ts +174 -0
  110. package/tui/drawer.ts +478 -0
  111. package/tui/grammar.ts +79 -12
  112. package/tui/guide.ts +351 -0
  113. package/tui/highway.ts +87 -4
  114. package/tui/hits.ts +68 -0
  115. package/tui/input.ts +7 -0
  116. package/tui/keys.ts +72 -0
@@ -1,16 +1,19 @@
1
1
  /**
2
2
  * Runs inside the evaluation subprocess (`bun --no-addons --no-install
3
3
  * --smol eval-child.ts <project>`): imports the project's `song.ts`, fills
4
- * sample hashes, and prints one JSON line. Never imported by dawg itself.
4
+ * sample hashes and Scala tuning files, and prints one JSON line. Never
5
+ * imported by dawg itself.
5
6
  */
6
7
 
7
8
  import { createHash } from "node:crypto";
8
- import { realpathSync } from "node:fs";
9
+ import { readFileSync, realpathSync, statSync } from "node:fs";
9
10
  import { readFile, stat } from "node:fs/promises";
10
11
  import { isAbsolute, join, relative, resolve } from "node:path";
11
12
  import { pathToFileURL } from "node:url";
13
+ import { readTuningFiles } from "../tuning.ts";
12
14
 
13
15
  const MAX_SAMPLE_FILE_BYTES = 50 * 1024 * 1024;
16
+ const MAX_TUNING_FILE_BYTES = 1024 * 1024;
14
17
 
15
18
  type Diagnostic = {
16
19
  message: string;
@@ -30,7 +33,10 @@ async function main(): Promise<void> {
30
33
  const song = module.default;
31
34
  if (typeof song !== "object" || song === null)
32
35
  throw new Error("song.ts must `export default song({...})`");
33
- output = { ok: true, score: await withSampleHashes(song, project) };
36
+ output = {
37
+ ok: true,
38
+ score: withTuningFiles(await withSampleHashes(song, project), project),
39
+ };
34
40
  } catch (error) {
35
41
  output = { ok: false, error: diagnostic(error, project) };
36
42
  }
@@ -122,4 +128,33 @@ async function withSampleHashes(
122
128
  return plain;
123
129
  }
124
130
 
131
+ /**
132
+ * Reads the Scala `.scl` and `.kbm` files a song or track tuning names, so
133
+ * the score carries the resolved table and renders without file access.
134
+ */
135
+ function withTuningFiles(score: unknown, project: string): unknown {
136
+ if (typeof score !== "object" || score === null) return score;
137
+ const plain = score as Record<string, unknown>;
138
+ const read = (path: string): string | undefined => {
139
+ const file = resolve(project, path);
140
+ if (relative(project, file).startsWith("..")) return undefined;
141
+ try {
142
+ const info = statSync(file);
143
+ if (!info.isFile() || info.size > MAX_TUNING_FILE_BYTES) return undefined;
144
+ return readFileSync(file, "utf8");
145
+ } catch {
146
+ return undefined;
147
+ }
148
+ };
149
+ if (plain.tuning) plain.tuning = readTuningFiles(plain.tuning, read, "song");
150
+ for (const track of Array.isArray(plain.tracks) ? plain.tracks : [])
151
+ if (typeof track === "object" && track !== null && track.tuning)
152
+ track.tuning = readTuningFiles(
153
+ track.tuning,
154
+ read,
155
+ `track ${String(track.name ?? track.id)}`,
156
+ );
157
+ return plain;
158
+ }
159
+
125
160
  await main();
package/core/sdk/print.ts CHANGED
@@ -8,8 +8,10 @@
8
8
  */
9
9
 
10
10
  import { DRUM_VOICES, isDrumInstrument } from "../drums.ts";
11
+ import { DEFAULT_FIXED_VELOCITY } from "../expression.ts";
11
12
  import type { RhythmRow } from "../euclid.ts";
12
13
  import { FX_LANES, fxSpec, type FxName, type FxValues } from "../fx.ts";
14
+ import { MASTER_SPECS, MASTER_UNITS, type SongMaster } from "../master.ts";
13
15
  import { midiToPitch } from "../pitch.ts";
14
16
  import { rhythmVoicePitch, rowInSync } from "../rhythm.ts";
15
17
  import {
@@ -23,12 +25,16 @@ import {
23
25
  type WavetableParam,
24
26
  TrackScore,
25
27
  type AutomationPoint,
28
+ type FormEntry,
26
29
  type Note,
27
30
  type SampleRef,
28
31
  type Sampler,
32
+ type Section,
29
33
  type Track,
30
34
  } from "../score.ts";
31
35
  import { trackSlug } from "../slug.ts";
36
+ import { barStartTick } from "../tempo.ts";
37
+ import type { Tuning } from "../tuning.ts";
32
38
  import { DEFAULT_HIT_LENGTH, DEFAULT_VELOCITY } from "./v1.ts";
33
39
 
34
40
  const WIDTH = 80;
@@ -147,7 +153,12 @@ export function trackIdentifiers(
147
153
  const out = new Map<string, string>();
148
154
  for (const track of score.tracks) {
149
155
  let base = dirs.get(track.id)!.replace(/-/g, "_");
150
- if (!/^[a-z_]/.test(base) || RESERVED.has(base)) base = `track_${base}`;
156
+ if (
157
+ !/^[a-z_]/.test(base) ||
158
+ RESERVED.has(base) ||
159
+ (score.time && TIME_HELPERS.includes(base))
160
+ )
161
+ base = `track_${base}`;
151
162
  let name = base;
152
163
  for (let n = 2; used.has(name); n += 1) name = `${base}_${n}`;
153
164
  used.add(name);
@@ -162,7 +173,12 @@ export function printSong(
162
173
  dirs: ReadonlyMap<string, string> = trackDirectories(score),
163
174
  ): string {
164
175
  const ids = trackIdentifiers(score, dirs);
165
- const lines: string[] = ['import { song } from "dawg";'];
176
+ const time = printTime(score);
177
+ const helpers = [
178
+ "song",
179
+ ...TIME_HELPERS.filter((name) => time.used.has(name)),
180
+ ];
181
+ const lines: string[] = [`import { ${helpers.join(", ")} } from "dawg";`];
166
182
  for (const track of score.tracks)
167
183
  lines.push(
168
184
  `import ${ids.get(track.id)!} from "./tracks/${dirs.get(track.id)!}/track.ts";`,
@@ -174,8 +190,14 @@ export function printSong(
174
190
  `bars: ${num(score.bars)}`,
175
191
  ];
176
192
  if (score.key !== null) entries.push(`key: ${str(score.key)}`);
193
+ if (score.tuning)
194
+ entries.push(
195
+ `tuning: ${printTuning(score.tuning, INDENT, "tuning: ".length)}`,
196
+ );
177
197
  if (score.ticksPerBeat !== 480)
178
198
  entries.push(`ticksPerBeat: ${num(score.ticksPerBeat)}`);
199
+ if (time.marks.length > 0)
200
+ entries.push(`time: ${list(time.marks, INDENT, "time: ".length, 1)}`);
179
201
  entries.push(
180
202
  `tracks: ${list(
181
203
  score.tracks.map((track) => ids.get(track.id)!),
@@ -184,12 +206,150 @@ export function printSong(
184
206
  1,
185
207
  )}`,
186
208
  );
209
+ if (score.master) entries.push(printMaster(score.master));
210
+ // Song sections and form (0.5): printed only when the song has them.
211
+ if (score.sections.length > 0)
212
+ entries.push(
213
+ `sections: ${list(
214
+ score.sections.map((section) => printSection(section, INDENT + INDENT)),
215
+ INDENT,
216
+ "sections: ".length,
217
+ 1,
218
+ )}`,
219
+ );
220
+ if (score.form.length > 0)
221
+ entries.push(`form: ${printForm(score.form, INDENT)}`);
222
+ if (score.loopSection !== undefined)
223
+ entries.push(`loopSection: ${str(score.loopSection)}`);
187
224
  lines.push("export default song({");
188
225
  for (const entry of entries) lines.push(`${INDENT}${entry},`);
189
226
  lines.push("});", "");
190
227
  return lines.join("\n");
191
228
  }
192
229
 
230
+ /** SDK time helpers `song.ts` may import, in import order. */
231
+ const TIME_HELPERS: readonly string[] = ["tempo", "ramp", "meter", "fermata"];
232
+
233
+ /** `song({ time })` marks for a score's tempo map, meters and fermatas. */
234
+ function printTime(score: TrackScore): {
235
+ marks: string[];
236
+ used: Set<string>;
237
+ } {
238
+ const marks: string[] = [];
239
+ const used = new Set<string>();
240
+ const time = score.time;
241
+ if (!time) return { marks, used };
242
+ const beat = (tick: number) => num(tick / score.ticksPerBeat);
243
+ type Mark = { tick: number; order: number; text: string };
244
+ const all: Mark[] = [];
245
+ for (const event of time.tempo ?? []) {
246
+ if (event.ramp) {
247
+ used.add("ramp");
248
+ const curve = event.ramp === "exp" ? ', "exp"' : "";
249
+ all.push({
250
+ tick: event.tick,
251
+ order: 1,
252
+ text: `ramp(${beat(event.tick)}, ${num(event.bpm)}${curve})`,
253
+ });
254
+ } else {
255
+ used.add("tempo");
256
+ all.push({
257
+ tick: event.tick,
258
+ order: 1,
259
+ text: `tempo(${beat(event.tick)}, ${num(event.bpm)})`,
260
+ });
261
+ }
262
+ }
263
+ for (const change of time.meter ?? []) {
264
+ used.add("meter");
265
+ const tick = barStartTick(score, change.bar);
266
+ const unit = change.beatUnit ?? 4;
267
+ const value =
268
+ unit === 4
269
+ ? num(change.beatsPerBar)
270
+ : `[${num(change.beatsPerBar)}, ${num(unit)}]`;
271
+ all.push({ tick, order: 0, text: `meter(${beat(tick)}, ${value})` });
272
+ }
273
+ for (const hold of time.fermatas ?? []) {
274
+ used.add("fermata");
275
+ all.push({
276
+ tick: hold.tick,
277
+ order: 2,
278
+ text: `fermata(${beat(hold.tick)}, ${num(hold.beats)})`,
279
+ });
280
+ }
281
+ all.sort((a, b) => a.tick - b.tick || a.order - b.order);
282
+ for (const mark of all) marks.push(mark.text);
283
+ return { marks, used };
284
+ }
285
+
286
+ /**
287
+ * One `sections` item: `{ name: "verse", startBar: 0, bars: 8 }`. `indent`
288
+ * is the item's own line; expanded fields sit one level deeper.
289
+ */
290
+ function printSection(section: Section, indent: string): string {
291
+ const inner = indent + INDENT;
292
+ const entries: (readonly [string, string])[] = [
293
+ ["name", str(section.name)],
294
+ ["startBar", num(section.startBar)],
295
+ ["bars", num(section.bars)],
296
+ ];
297
+ if (section.mute && section.mute.length > 0)
298
+ entries.push([
299
+ "mute",
300
+ list(section.mute.map(str), inner, "mute: ".length, 1),
301
+ ]);
302
+ if (section.vary) {
303
+ const vary = Object.entries(section.vary).map(([id, change]) => {
304
+ const key = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(id) ? id : str(id);
305
+ const fields: (readonly [string, string])[] = [];
306
+ if (change.transpose !== undefined)
307
+ fields.push(["transpose", num(change.transpose)]);
308
+ if (change.gain !== undefined) fields.push(["gain", num(change.gain)]);
309
+ return [key, obj(fields, inner + INDENT, `${key}: `.length, 1)] as const;
310
+ });
311
+ if (vary.length > 0)
312
+ entries.push(["vary", obj(vary, inner, "vary: ".length, 1)]);
313
+ }
314
+ return obj(entries, indent, 0, 1);
315
+ }
316
+
317
+ /**
318
+ * The form as a string when every name is plain (`"intro verse chorus*2"`),
319
+ * else as a list of entries.
320
+ */
321
+ function printForm(form: readonly FormEntry[], indent: string): string {
322
+ // Word characters only, so the string reads back as the same entries.
323
+ const plain = form.every((entry) => /^\w+$/u.test(entry.section));
324
+ if (plain) {
325
+ const text = form
326
+ .map(
327
+ (entry) =>
328
+ `${entry.section}${(entry.repeat ?? 1) > 1 ? `*${entry.repeat}` : ""}`,
329
+ )
330
+ .join(" ");
331
+ return str(text);
332
+ }
333
+ return list(
334
+ form.map((entry) =>
335
+ (entry.repeat ?? 1) > 1
336
+ ? obj(
337
+ [
338
+ ["section", str(entry.section)],
339
+ ["repeat", num(entry.repeat!)],
340
+ ],
341
+ indent + INDENT,
342
+ 0,
343
+ 1,
344
+ )
345
+ : str(entry.section),
346
+ ),
347
+ indent,
348
+ "form: ".length,
349
+ 1,
350
+ );
351
+ }
352
+
193
353
  /** `tracks/<slug>/track.ts` for one track of a score. */
194
354
  export function printTrack(score: TrackScore, track: Track): string {
195
355
  // Rows whose lane still matches their expansion print as generators and
@@ -213,13 +373,13 @@ export function printTrack(score: TrackScore, track: Track): string {
213
373
  if (slots) for (const [voice, slot] of slots) voiceFor.set(slot, voice);
214
374
  const used = new Set<string>(["track"]);
215
375
  const printed = notes.map((note) => {
216
- const voice = voiceFor.get(note.pitch);
376
+ const voice = note.cents ? undefined : voiceFor.get(note.pitch);
217
377
  if (voice !== undefined) {
218
378
  used.add("hit");
219
- return printHit(score, note, voice);
379
+ return printHit(score, note, voice, INDENT + INDENT);
220
380
  }
221
381
  used.add("note");
222
- return printNote(score, note, !kit && !slots);
382
+ return printNote(score, note, !kit && !slots, INDENT + INDENT);
223
383
  });
224
384
  if (track.sampler) used.add("sampler");
225
385
  const wavetable = isWavetableInstrument(track.instrument)
@@ -237,6 +397,28 @@ export function printTrack(score: TrackScore, track: Track): string {
237
397
  entries.push(`instrument: ${printWavetable(wavetable, INDENT)}`);
238
398
  } else entries.push(`instrument: ${str(track.instrument)}`);
239
399
  if (track.kit) entries.push(`kit: ${str(track.kit)}`);
400
+ if (track.time) {
401
+ const beats = (ticks: number) => num(ticks / score.ticksPerBeat);
402
+ const fields: (readonly [string, string])[] = [];
403
+ if (track.time.rate !== undefined)
404
+ fields.push(["rate", num(track.time.rate)]);
405
+ if (track.time.phase !== undefined)
406
+ fields.push(["phase", beats(track.time.phase)]);
407
+ if (track.time.cycle !== undefined)
408
+ fields.push(["cycle", beats(track.time.cycle)]);
409
+ if (track.time.steps) {
410
+ const { shift, hold, drift } = track.time.steps;
411
+ fields.push([
412
+ "steps",
413
+ `{ shift: ${beats(shift)}, hold: ${num(hold)}, drift: ${num(drift)} }`,
414
+ ]);
415
+ }
416
+ entries.push(`time: ${obj(fields, INDENT, "time: ".length, 1)}`);
417
+ }
418
+ if (track.tuning)
419
+ entries.push(
420
+ `tuning: ${printTuning(track.tuning, INDENT, "tuning: ".length)}`,
421
+ );
240
422
  if (track.muted) entries.push("muted: true");
241
423
  if (track.solo) entries.push("solo: true");
242
424
  if (track.volume !== 1) entries.push(`volume: ${num(track.volume)}`);
@@ -324,6 +506,7 @@ export function printTrack(score: TrackScore, track: Track): string {
324
506
  );
325
507
  entries.push(`synth: ${obj(params, INDENT, "synth: ".length, 1)}`);
326
508
  }
509
+ entries.push(...performanceEntries(score, track));
327
510
  const lanes: [string, readonly AutomationPoint[] | undefined][] = [
328
511
  ["volume", track.volumeAutomation],
329
512
  ["pan", track.panAutomation],
@@ -407,23 +590,185 @@ export function printTrack(score: TrackScore, track: Track): string {
407
590
  return lines.join("\n");
408
591
  }
409
592
 
410
- function printNote(score: TrackScore, note: Note, named: boolean): string {
411
- const pitch = named ? str(midiToPitch(note.pitch)) : num(note.pitch);
593
+ function printNote(
594
+ score: TrackScore,
595
+ note: Note,
596
+ named: boolean,
597
+ indent: string,
598
+ ): string {
599
+ // A detuned note always prints by name: `"E4-14c"`.
600
+ const pitch = note.cents
601
+ ? str(
602
+ `${midiToPitch(note.pitch)}${note.cents > 0 ? "+" : ""}${num(note.cents)}c`,
603
+ )
604
+ : named
605
+ ? str(midiToPitch(note.pitch))
606
+ : num(note.pitch);
412
607
  const args = [pitch, num(note.startTick / score.ticksPerBeat)];
413
608
  const length = note.durationTicks / score.ticksPerBeat;
414
- if (length !== 1 || note.velocity !== DEFAULT_VELOCITY)
609
+ const how = expressionEntries(note, indent + INDENT);
610
+ if (length !== 1 || note.velocity !== DEFAULT_VELOCITY || how)
415
611
  args.push(num(length));
416
- if (note.velocity !== DEFAULT_VELOCITY) args.push(num(note.velocity));
417
- return `note(${args.join(", ")})`;
612
+ if (note.velocity !== DEFAULT_VELOCITY || how) args.push(num(note.velocity));
613
+ return call("note", args, how, indent);
418
614
  }
419
615
 
420
- function printHit(score: TrackScore, note: Note, voice: string): string {
616
+ function printHit(
617
+ score: TrackScore,
618
+ note: Note,
619
+ voice: string,
620
+ indent: string,
621
+ ): string {
421
622
  const args = [str(voice), num(note.startTick / score.ticksPerBeat)];
422
623
  const length = note.durationTicks / score.ticksPerBeat;
423
- if (note.velocity !== DEFAULT_VELOCITY || length !== DEFAULT_HIT_LENGTH)
624
+ const how = expressionEntries(note, indent + INDENT);
625
+ if (
626
+ note.velocity !== DEFAULT_VELOCITY ||
627
+ length !== DEFAULT_HIT_LENGTH ||
628
+ how
629
+ )
424
630
  args.push(num(note.velocity));
425
- if (length !== DEFAULT_HIT_LENGTH) args.push(num(length));
426
- return `hit(${args.join(", ")})`;
631
+ if (length !== DEFAULT_HIT_LENGTH || how) args.push(num(length));
632
+ return call("hit", args, how, indent);
633
+ }
634
+
635
+ /**
636
+ * A note's expression as the fields of its `how` argument, values laid out
637
+ * for property lines at `indent`; undefined when it has none.
638
+ */
639
+ function expressionEntries(
640
+ note: Note,
641
+ indent: string,
642
+ ): (readonly [string, string])[] | undefined {
643
+ const entries: (readonly [string, string])[] = [];
644
+ if (note.articulation) entries.push(["art", str(note.articulation)]);
645
+ if (note.glide !== undefined) entries.push(["glide", num(note.glide)]);
646
+ if (note.bend) {
647
+ const points = note.bend.map((p) => `[${num(p.at)}, ${num(p.cents)}]`);
648
+ // Prettier forces a break on arrays of 2+ arrays that each hold 2+ items.
649
+ entries.push([
650
+ "bend",
651
+ list(points, indent, "bend: ".length, 1, points.length > 1),
652
+ ]);
653
+ }
654
+ if (note.vibrato)
655
+ entries.push([
656
+ "vibrato",
657
+ obj(
658
+ [
659
+ ["rate", num(note.vibrato.rate)],
660
+ ["depth", num(note.vibrato.depth)],
661
+ ...(note.vibrato.delay !== undefined
662
+ ? ([["delay", num(note.vibrato.delay)]] as const)
663
+ : []),
664
+ ],
665
+ indent,
666
+ "vibrato: ".length,
667
+ 1,
668
+ ),
669
+ ]);
670
+ if (note.humanize) {
671
+ const { timing, velocity, length } = note.humanize;
672
+ entries.push([
673
+ "humanize",
674
+ obj(
675
+ [
676
+ ...(timing !== undefined ? ([["timing", num(timing)]] as const) : []),
677
+ ...(velocity !== undefined
678
+ ? ([["velocity", num(velocity)]] as const)
679
+ : []),
680
+ ...(length !== undefined ? ([["length", num(length)]] as const) : []),
681
+ ],
682
+ indent,
683
+ "humanize: ".length,
684
+ 1,
685
+ ),
686
+ ]);
687
+ }
688
+ return entries.length === 0 ? undefined : entries;
689
+ }
690
+
691
+ /**
692
+ * `name(args, how)` as a list item at `indent`: on one line when it fits,
693
+ * otherwise with the trailing `how` object hugged and broken one field per
694
+ * line, the way prettier prints a last object argument.
695
+ */
696
+ function call(
697
+ name: string,
698
+ args: readonly string[],
699
+ how: readonly (readonly [string, string])[] | undefined,
700
+ indent: string,
701
+ ): string {
702
+ if (!how) return `${name}(${args.join(", ")})`;
703
+ const inline = `${name}(${[...args, `{ ${how.map(([k, v]) => `${k}: ${v}`).join(", ")} }`].join(", ")})`;
704
+ if (!inline.includes("\n") && indent.length + inline.length + 1 <= WIDTH)
705
+ return inline;
706
+ const inner = indent + INDENT;
707
+ const body = how.map(([k, v]) => property(k, v, inner)).join("\n");
708
+ return `${name}(${[...args, "{"].join(", ")}\n${body}\n${indent}})`;
709
+ }
710
+
711
+ /** A track's performance fields (SDK 1.15.0) as `key: literal` entries. */
712
+ function performanceEntries(score: TrackScore, track: Track): string[] {
713
+ const entries: string[] = [];
714
+ if (track.glide)
715
+ entries.push(
716
+ track.glide.mode === "legato"
717
+ ? `glide: ${num(track.glide.time)}`
718
+ : `glide: ${obj(
719
+ [
720
+ ["time", num(track.glide.time)],
721
+ ["mode", str(track.glide.mode)],
722
+ ],
723
+ INDENT,
724
+ "glide: ".length,
725
+ 1,
726
+ )}`,
727
+ );
728
+ if (track.pedal) {
729
+ const events = track.pedal.map(
730
+ (event) =>
731
+ `[${num(event.tick / score.ticksPerBeat)}, ${str(event.state)}]`,
732
+ );
733
+ entries.push(
734
+ `pedal: ${list(events, INDENT, "pedal: ".length, 1, events.length > 1)}`,
735
+ );
736
+ }
737
+ if (track.velocityCurve)
738
+ entries.push(
739
+ track.velocityCurve.curve !== "fixed" ||
740
+ (track.velocityCurve.fixed ?? DEFAULT_FIXED_VELOCITY) ===
741
+ DEFAULT_FIXED_VELOCITY
742
+ ? `velocityCurve: ${str(track.velocityCurve.curve)}`
743
+ : `velocityCurve: ${obj(
744
+ [
745
+ ["curve", str(track.velocityCurve.curve)],
746
+ ["fixed", num(track.velocityCurve.fixed!)],
747
+ ],
748
+ INDENT,
749
+ "velocityCurve: ".length,
750
+ 1,
751
+ )}`,
752
+ );
753
+ if (track.humanize) {
754
+ const { timing, velocity, length, seed } = track.humanize;
755
+ entries.push(
756
+ `humanize: ${obj(
757
+ [
758
+ ...(timing !== undefined ? ([["timing", num(timing)]] as const) : []),
759
+ ...(velocity !== undefined
760
+ ? ([["velocity", num(velocity)]] as const)
761
+ : []),
762
+ ...(length !== undefined ? ([["length", num(length)]] as const) : []),
763
+ ["seed", num(seed)],
764
+ ],
765
+ INDENT,
766
+ "humanize: ".length,
767
+ 1,
768
+ )}`,
769
+ );
770
+ }
771
+ return entries;
427
772
  }
428
773
 
429
774
  /** `euclid("hat", 7, 16, 2, { velocity: 0.5 })` or `grid("sd", "....x...")`. */
@@ -574,6 +919,92 @@ function printSample(ref: SampleRef, indent: string, prefix: number): string {
574
919
  return obj(entries, indent, prefix, 1);
575
920
  }
576
921
 
922
+ /**
923
+ * A song or track tuning: a bare library name, or an object without the
924
+ * fields dawg resolved from Scala files (the table of an `scl`, the keymap
925
+ * of a `kbm`).
926
+ */
927
+ function printTuning(tuning: Tuning, indent: string, prefix: number): string {
928
+ const entries: [string, string][] = [];
929
+ if (tuning.name !== undefined) entries.push(["name", str(tuning.name)]);
930
+ if (tuning.scl === undefined) {
931
+ const inner = indent + INDENT;
932
+ if (tuning.edo !== undefined) entries.push(["edo", num(tuning.edo)]);
933
+ if (tuning.ratios)
934
+ entries.push([
935
+ "ratios",
936
+ list(tuning.ratios.map(str), inner, "ratios: ".length, 1),
937
+ ]);
938
+ if (tuning.cents)
939
+ entries.push([
940
+ "cents",
941
+ fill(tuning.cents.map(num), inner, "cents: ".length, 1),
942
+ ]);
943
+ } else entries.push(["scl", str(tuning.scl)]);
944
+ if (tuning.kbm !== undefined) entries.push(["kbm", str(tuning.kbm)]);
945
+ if (tuning.ref !== undefined) entries.push(["ref", num(tuning.ref)]);
946
+ if (tuning.root !== undefined)
947
+ entries.push(["root", str(midiToPitch(tuning.root))]);
948
+ if (tuning.map !== undefined) entries.push(["map", str(tuning.map)]);
949
+ if (entries.length === 1 && entries[0]![0] === "name") return entries[0]![1];
950
+ return obj(entries, indent, prefix, 1);
951
+ }
952
+
953
+ /**
954
+ * Number array the way prettier prints one: inline when it fits, else
955
+ * filled (as many per line as fit, each followed by a comma).
956
+ */
957
+ function fill(
958
+ items: readonly string[],
959
+ indent: string,
960
+ prefix: number,
961
+ trailing: number,
962
+ ): string {
963
+ const inline = `[${items.join(", ")}]`;
964
+ if (
965
+ items.length < 2 ||
966
+ indent.length + prefix + inline.length + trailing <= WIDTH
967
+ )
968
+ return inline;
969
+ const inner = indent + INDENT;
970
+ const lines: string[] = [];
971
+ let line = "";
972
+ for (const item of items) {
973
+ const next = `${item},`;
974
+ if (line !== "" && inner.length + line.length + 1 + next.length <= WIDTH)
975
+ line += ` ${next}`;
976
+ else {
977
+ if (line !== "") lines.push(inner + line);
978
+ line = next;
979
+ }
980
+ }
981
+ lines.push(inner + line);
982
+ return `[\n${lines.join("\n")}\n${indent}]`;
983
+ }
984
+
985
+ /** `master: { … }`, always expanded like `fx`; units in chain order. */
986
+ function printMaster(master: SongMaster): string {
987
+ const inner = INDENT + INDENT;
988
+ const body: string[] = [];
989
+ for (const unit of MASTER_UNITS) {
990
+ const values = master[unit];
991
+ if (!values) continue;
992
+ // Only values that differ from the default: loading fills the rest.
993
+ const params = Object.entries(MASTER_SPECS[unit].params)
994
+ .filter(
995
+ ([key, spec]) =>
996
+ values[key] !== undefined && values[key] !== spec.default,
997
+ )
998
+ .map(([key]) => [key, value(values[key]!)] as const);
999
+ body.push(
1000
+ `${inner}${unit}: ${params.length === 0 ? "{}" : obj(params, inner, `${unit}: `.length, 1)},`,
1001
+ );
1002
+ }
1003
+ if (master.target !== undefined)
1004
+ body.push(`${inner}target: ${num(master.target)},`);
1005
+ return `master: {\n${body.join("\n")}\n${INDENT}}`;
1006
+ }
1007
+
577
1008
  /** Object literal: inline when the line fits, expanded otherwise (prettier keeps both). */
578
1009
  function obj(
579
1010
  entries: readonly (readonly [string, string])[],