@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,490 @@
1
+ /**
2
+ * `/tuning` and `/scale`: song and track tuning, and the scale library.
3
+ *
4
+ * tuning the song and focused track tuning
5
+ * tuning list the library, by family
6
+ * tuning <name> a library tuning (19-edo, just, pelog…)
7
+ * tuning edo <n> n equal steps per octave
8
+ * tuning ratios 9/8 5/4 … 2/1 just ratios (the last is the period)
9
+ * tuning cents 204 386 … 1200 cents (the last is the period)
10
+ * tuning scl <file> [kbm <file>] a Scala scale (and keyboard map)
11
+ * tuning kbm <file>|off a Scala keyboard map
12
+ * tuning ref <hz>|off 12-TET A4 in Hz that fixes the root (440)
13
+ * tuning root <note>|auto the key of degree 0
14
+ * tuning map linear|nearest one key per step, or nearest of 12 keys
15
+ * tuning off back to 12-TET
16
+ * tuning track … the same for the focused track
17
+ * (`tuning track off` follows the song)
18
+ * scale the song key
19
+ * scale list the scale library
20
+ * scale <name> keep the tonic, change the scale
21
+ * scale <tonic> <name> set the song key (`scale D hijaz`)
22
+ *
23
+ * Scala files live in the project: a path outside it is copied into
24
+ * `tunings/` first (`importTuningFile`).
25
+ */
26
+ import { copyFile, mkdir, readFile, stat } from "node:fs/promises";
27
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
28
+
29
+ import {
30
+ MODE_NAMES,
31
+ SCALES,
32
+ SCALE_NAMES,
33
+ keyName,
34
+ keyUsesFlats,
35
+ noteName,
36
+ parseKey,
37
+ scaleSteps,
38
+ type Key,
39
+ type ScaleInfo,
40
+ } from "../../core/chords.ts";
41
+ import { midiToPitch, pitchToMidi } from "../../core/pitch.ts";
42
+ import {
43
+ applyScoreOperation,
44
+ type TrackScore,
45
+ type Tuning,
46
+ } from "../../core/score.ts";
47
+ import {
48
+ TUNING_PRESETS,
49
+ TuningError,
50
+ describeTuning,
51
+ normalizeTuning,
52
+ readTuningFiles,
53
+ tuningPreset,
54
+ tuningTable,
55
+ type TuningMap,
56
+ } from "../../core/tuning.ts";
57
+
58
+ export type TuningTarget = "song" | "track";
59
+
60
+ /** Fields one `/tuning` command changes; `null` clears a field. */
61
+ export type TuningPatch = Readonly<{
62
+ name?: string;
63
+ edo?: number;
64
+ ratios?: readonly string[];
65
+ cents?: readonly number[];
66
+ scl?: string;
67
+ kbm?: string | null;
68
+ ref?: number | null;
69
+ root?: number | null;
70
+ map?: TuningMap;
71
+ }>;
72
+
73
+ export type TuningCommand =
74
+ | { type: "tuning-show" }
75
+ | { type: "tuning-list" }
76
+ | { type: "tuning-off"; target: TuningTarget }
77
+ | { type: "tuning-set"; target: TuningTarget; patch: TuningPatch }
78
+ | { type: "scale-show" }
79
+ | { type: "scale-list" }
80
+ /** `key` is a whole key (`D hijaz`); `scale` keeps the song's tonic. */
81
+ | { type: "scale-set"; key?: string; scale?: string };
82
+
83
+ export type TuningResult = Readonly<{
84
+ ok: boolean;
85
+ message: string;
86
+ next?: TrackScore;
87
+ kind?: string;
88
+ payload?: Record<string, unknown>;
89
+ /** A text panel to open (`tuning list`, `scale list`). */
90
+ panel?: Readonly<{ title: string; lines: readonly string[] }>;
91
+ }>;
92
+
93
+ /** Reads a project file's text, or undefined when it is missing. */
94
+ export type ProjectRead = (path: string) => string | undefined;
95
+
96
+ const TABLE_FIELDS = ["name", "edo", "ratios", "cents", "scl"] as const;
97
+
98
+ /** Parses `/tuning …` or `/scale …` (slash optional), else undefined. */
99
+ export function parseTuningCommand(prompt: string): TuningCommand | undefined {
100
+ if (prompt.length > 2_048) return undefined;
101
+ const words = prompt.trim().replace(/^\//, "").split(/\s+/);
102
+ const verb = words[0]?.toLowerCase();
103
+ if (verb === "scale" || verb === "scales") return parseScale(words.slice(1));
104
+ if (verb !== "tuning" && verb !== "tune" && verb !== "tunings")
105
+ return undefined;
106
+ let rest = words.slice(1);
107
+ if (rest.length === 0) return { type: "tuning-show" };
108
+ if (rest.length === 1 && /^(list|ls)$/i.test(rest[0]!))
109
+ return { type: "tuning-list" };
110
+ let target: TuningTarget = "song";
111
+ if (rest[0]!.toLowerCase() === "track") {
112
+ target = "track";
113
+ rest = rest.slice(1);
114
+ if (rest.length === 0) return { type: "tuning-show" };
115
+ } else if (rest[0]!.toLowerCase() === "song") {
116
+ rest = rest.slice(1);
117
+ if (rest.length === 0) return { type: "tuning-show" };
118
+ }
119
+ const head = rest[0]!.toLowerCase();
120
+ const args = rest.slice(1);
121
+ if (rest.length === 1 && /^(off|none|reset|clear|default)$/.test(head))
122
+ return { type: "tuning-off", target };
123
+ const set = (patch: TuningPatch): TuningCommand => ({
124
+ type: "tuning-set",
125
+ target,
126
+ patch,
127
+ });
128
+ switch (head) {
129
+ case "edo":
130
+ case "tet":
131
+ case "et": {
132
+ const edo = Number(args[0]);
133
+ return args.length === 1 && Number.isInteger(edo)
134
+ ? set({ edo })
135
+ : undefined;
136
+ }
137
+ case "ratios":
138
+ case "ratio":
139
+ case "ji":
140
+ return args.length > 0 ? set({ ratios: args }) : undefined;
141
+ case "cents": {
142
+ const cents = args.map(Number);
143
+ return args.length > 0 && cents.every(Number.isFinite)
144
+ ? set({ cents })
145
+ : undefined;
146
+ }
147
+ case "scl":
148
+ case "scala": {
149
+ if (args.length === 1) return set({ scl: args[0]! });
150
+ if (args.length === 3 && args[1]!.toLowerCase() === "kbm")
151
+ return set({ scl: args[0]!, kbm: args[2]! });
152
+ return undefined;
153
+ }
154
+ case "kbm":
155
+ case "keymap":
156
+ if (args.length !== 1) return undefined;
157
+ return set({ kbm: isOff(args[0]!) ? null : args[0]! });
158
+ case "ref":
159
+ case "a4":
160
+ case "pitch": {
161
+ if (args.length !== 1) return undefined;
162
+ if (isOff(args[0]!)) return set({ ref: null });
163
+ const ref = Number(args[0]!.replace(/hz$/i, ""));
164
+ return Number.isFinite(ref) ? set({ ref }) : undefined;
165
+ }
166
+ case "root": {
167
+ if (args.length !== 1) return undefined;
168
+ if (isOff(args[0]!) || args[0]!.toLowerCase() === "auto")
169
+ return set({ root: null });
170
+ const root = rootKey(args[0]!);
171
+ return root === undefined ? undefined : set({ root });
172
+ }
173
+ case "map":
174
+ return args.length === 1 &&
175
+ (args[0] === "linear" || args[0] === "nearest")
176
+ ? set({ map: args[0] })
177
+ : undefined;
178
+ }
179
+ // Library names first (`19-edo`, `pelog`), then `/tuning 22edo` or `22`.
180
+ const name = rest.join(" ");
181
+ if (tuningPreset(name)) return set({ name });
182
+ const edo = name.match(/^(\d{1,3})(?:[- ]?(?:edo|tet|et))?$/i);
183
+ return edo ? set({ edo: Number(edo[1]) }) : undefined;
184
+ }
185
+
186
+ function parseScale(words: readonly string[]): TuningCommand | undefined {
187
+ if (words.length === 0) return { type: "scale-show" };
188
+ if (words.length === 1 && /^(list|ls)$/i.test(words[0]!))
189
+ return { type: "scale-list" };
190
+ const text = words.join(" ");
191
+ const key = parseKey(text);
192
+ if (key && /^[a-g](#|b|♯|♭)?m?$/i.test(words[0]!))
193
+ return { type: "scale-set", key: keyName(key) };
194
+ return parseKey(`C ${text}`) ? { type: "scale-set", scale: text } : undefined;
195
+ }
196
+
197
+ function isOff(word: string): boolean {
198
+ return /^(off|none|clear|default)$/i.test(word);
199
+ }
200
+
201
+ /** `D4`, `eb3` or a MIDI number 0..127. */
202
+ function rootKey(text: string): number | undefined {
203
+ if (/^\d{1,3}$/.test(text)) {
204
+ const key = Number(text);
205
+ return key <= 127 ? key : undefined;
206
+ }
207
+ // A bare pitch class (`C`, `F#`, `Bb`) means the octave from middle C.
208
+ if (/^[a-g](?:#|b|♯|♭)?$/i.test(text)) text = `${text}4`;
209
+ try {
210
+ const key = pitchToMidi(text);
211
+ return Number.isInteger(key) && key >= 0 && key <= 127 ? key : undefined;
212
+ } catch {
213
+ return undefined;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Applies a parsed command to `score`. `trackId` is the focused track
219
+ * (`tuning track …`); `read` loads `.scl`/`.kbm` project files.
220
+ */
221
+ export function applyTuningCommand(
222
+ score: TrackScore,
223
+ trackId: string,
224
+ command: TuningCommand,
225
+ read: ProjectRead,
226
+ ): TuningResult {
227
+ switch (command.type) {
228
+ case "tuning-show":
229
+ return { ok: true, message: tuningSummary(score, trackId) };
230
+ case "tuning-list":
231
+ return {
232
+ ok: true,
233
+ message: `${TUNING_PRESETS.length} tunings · tuning <name>`,
234
+ panel: { title: "tunings", lines: tuningLibraryLines() },
235
+ };
236
+ case "scale-show":
237
+ return { ok: true, message: scaleSummary(score.key) };
238
+ case "scale-list":
239
+ return {
240
+ ok: true,
241
+ message: `${MODE_NAMES.length + SCALE_NAMES.length} scales · scale <tonic> <name>`,
242
+ panel: { title: "scales", lines: scaleLibraryLines() },
243
+ };
244
+ case "scale-set":
245
+ return setScale(score, command);
246
+ case "tuning-off":
247
+ return command.target === "song"
248
+ ? songTuning(score, null)
249
+ : trackTuning(score, trackId, null);
250
+ case "tuning-set": {
251
+ const current =
252
+ command.target === "song"
253
+ ? score.tuning
254
+ : score.tracks.find((track) => track.id === trackId)?.tuning;
255
+ if (command.target === "track" && !hasTrack(score, trackId))
256
+ return { ok: false, message: `no track · ${trackId}` };
257
+ const where =
258
+ command.target === "song" ? "song tuning" : `track ${trackId} tuning`;
259
+ let tuning: Tuning | undefined;
260
+ try {
261
+ tuning = mergeTuning(current, command.patch, read, where);
262
+ } catch (error) {
263
+ if (error instanceof TuningError)
264
+ return { ok: false, message: `tuning · ${error.message}` };
265
+ throw error;
266
+ }
267
+ return command.target === "song"
268
+ ? songTuning(score, tuning ?? null)
269
+ : trackTuning(score, trackId, tuning ?? null);
270
+ }
271
+ }
272
+ }
273
+
274
+ /**
275
+ * The tuning after `patch`: a new table replaces the old one and keeps the
276
+ * reference, root, map and keyboard map; Scala files are read again so the
277
+ * project files stay the source of truth.
278
+ */
279
+ export function mergeTuning(
280
+ current: Tuning | undefined,
281
+ patch: TuningPatch,
282
+ read: ProjectRead,
283
+ where: string,
284
+ ): Tuning | undefined {
285
+ const plain: Record<string, unknown> = { ...(current ?? {}) };
286
+ // Resolved fields come back from the files below.
287
+ if (plain.scl !== undefined) {
288
+ delete plain.cents;
289
+ delete plain.ratios;
290
+ }
291
+ delete plain.keymap;
292
+ if (TABLE_FIELDS.some((field) => patch[field] !== undefined))
293
+ for (const field of TABLE_FIELDS) delete plain[field];
294
+ for (const [field, value] of Object.entries(patch)) {
295
+ if (value === null) delete plain[field];
296
+ else plain[field] = value;
297
+ }
298
+ // A keyboard map sets its own reference and root.
299
+ if (patch.kbm) {
300
+ delete plain.ref;
301
+ delete plain.root;
302
+ delete plain.map;
303
+ }
304
+ if ((patch.ref != null || patch.root != null || patch.map) && plain.kbm)
305
+ throw new TuningError(
306
+ `${where}: the keyboard map ${String(plain.kbm)} sets the reference and root (tuning kbm off first)`,
307
+ );
308
+ if (Object.keys(plain).length === 0) return undefined;
309
+ return normalizeTuning(readTuningFiles(plain, read, where), where);
310
+ }
311
+
312
+ function hasTrack(score: TrackScore, trackId: string): boolean {
313
+ return score.tracks.some((track) => track.id === trackId);
314
+ }
315
+
316
+ function songTuning(score: TrackScore, tuning: Tuning | null): TuningResult {
317
+ return {
318
+ ok: true,
319
+ message: `tuning · ${describeTuning(tuning ?? undefined)}`,
320
+ next: applyScoreOperation(score, { type: "setTuning", tuning }),
321
+ kind: "score.tuning",
322
+ payload: { tuning },
323
+ };
324
+ }
325
+
326
+ function trackTuning(
327
+ score: TrackScore,
328
+ trackId: string,
329
+ tuning: Tuning | null,
330
+ ): TuningResult {
331
+ if (!hasTrack(score, trackId))
332
+ return { ok: false, message: `no track · ${trackId}` };
333
+ return {
334
+ ok: true,
335
+ message: `tuning · ${trackId} · ${tuning ? describeTuning(tuning) : "follows the song"}`,
336
+ next: applyScoreOperation(score, {
337
+ type: "updateTrack",
338
+ trackId,
339
+ patch: { tuning },
340
+ }),
341
+ kind: "score.track",
342
+ payload: { trackId, patch: { tuning } },
343
+ };
344
+ }
345
+
346
+ function setScale(
347
+ score: TrackScore,
348
+ command: Extract<TuningCommand, { type: "scale-set" }>,
349
+ ): TuningResult {
350
+ let key = command.key ? parseKey(command.key) : undefined;
351
+ if (!key && command.scale) {
352
+ const current = parseKey(score.key ?? undefined);
353
+ const tonic = current
354
+ ? noteName(current.tonic, keyUsesFlats(current))
355
+ : "C";
356
+ key = parseKey(`${tonic} ${command.scale}`);
357
+ }
358
+ if (!key)
359
+ return {
360
+ ok: false,
361
+ message: `scale · unknown scale ${command.scale ?? command.key ?? ""}`,
362
+ };
363
+ const name = keyName(key);
364
+ const info: ScaleInfo | undefined = key.scale ? SCALES[key.scale] : undefined;
365
+ const hint =
366
+ info?.intonation || info?.steps.some((step) => !Number.isInteger(step))
367
+ ? ` · tuning ${key.scale} for its intonation`
368
+ : "";
369
+ return {
370
+ ok: true,
371
+ message: `${scaleSummary(name)}${hint}`,
372
+ next: applyScoreOperation(score, { type: "setKey", key: name }),
373
+ kind: "score.key",
374
+ payload: { key: name },
375
+ };
376
+ }
377
+
378
+ /** `tuning · song 19-edo · lead follows the song`. */
379
+ export function tuningSummary(score: TrackScore, trackId: string): string {
380
+ const track = score.tracks.find((item) => item.id === trackId);
381
+ const song = `song ${describeTuning(score.tuning)}`;
382
+ if (!track) return `tuning · ${song}`;
383
+ const own = track.tuning ? describeTuning(track.tuning) : "follows the song";
384
+ return `tuning · ${song} · ${trackId} ${own}`;
385
+ }
386
+
387
+ /** `scale · D bayati · 0 1.5 3 5 7 8 10`. */
388
+ export function scaleSummary(key: string | null | undefined): string {
389
+ const parsed = parseKey(key ?? undefined);
390
+ if (!parsed) return "scale · none (C major assumed) · scale <tonic> <name>";
391
+ return `scale · ${keyName(parsed)} · ${scaleSteps(parsed).map(formatStep).join(" ")}`;
392
+ }
393
+
394
+ function formatStep(step: number): string {
395
+ return Number.isInteger(step) ? String(step) : step.toFixed(1);
396
+ }
397
+
398
+ /** One line per library tuning, grouped by family. */
399
+ export function tuningLibraryLines(): string[] {
400
+ const lines: string[] = [];
401
+ let family = "";
402
+ for (const preset of TUNING_PRESETS) {
403
+ if (preset.family !== family) {
404
+ family = preset.family;
405
+ if (lines.length > 0) lines.push("");
406
+ lines.push(family);
407
+ }
408
+ const steps = preset.cents.length;
409
+ lines.push(
410
+ ` ${preset.name.padEnd(18)} ${String(steps).padStart(2)} steps · ${preset.about}${preset.approximate ? " (approximate)" : ""}`,
411
+ );
412
+ }
413
+ return lines;
414
+ }
415
+
416
+ /** One line per mode and library scale, grouped by family. */
417
+ export function scaleLibraryLines(): string[] {
418
+ const lines = ["modes"];
419
+ for (const mode of MODE_NAMES)
420
+ lines.push(
421
+ ` ${mode.padEnd(18)} ${stepsText(scaleSteps({ tonic: 0, mode } as Key))}`,
422
+ );
423
+ let family = "";
424
+ for (const name of SCALE_NAMES) {
425
+ const info: ScaleInfo = SCALES[name];
426
+ if (info.family !== family) {
427
+ family = info.family;
428
+ lines.push("", family);
429
+ }
430
+ lines.push(` ${name.padEnd(18)} ${stepsText(info.steps)}`);
431
+ }
432
+ return lines;
433
+ }
434
+
435
+ function stepsText(steps: readonly number[]): string {
436
+ return steps.map(formatStep).join(" ");
437
+ }
438
+
439
+ /** The cents of a tuning's degrees 1..n, for the menu and `/tuning`. */
440
+ export function tuningStepsText(tuning: Tuning | undefined): string {
441
+ const table = tuning ? tuningTable(tuning) : undefined;
442
+ if (!table) return "100 200 … 1200";
443
+ const shown = table
444
+ .slice(0, 12)
445
+ .map((cents) => cents.toFixed(1).replace(/\.0$/, ""));
446
+ return table.length > 12 ? `${shown.join(" ")} …` : shown.join(" ");
447
+ }
448
+
449
+ /** The root key as a name (`D4`), for messages. */
450
+ export function rootName(root: number | undefined): string {
451
+ return root === undefined ? "auto" : midiToPitch(root);
452
+ }
453
+
454
+ /**
455
+ * Makes a `.scl`/`.kbm` path the user typed into a project path: a file
456
+ * inside `projectRoot` keeps its relative path; one outside is copied into
457
+ * `tunings/` (a numbered name when a different file has the name already).
458
+ */
459
+ export async function importTuningFile(
460
+ projectRoot: string,
461
+ cwd: string,
462
+ input: string,
463
+ ): Promise<string> {
464
+ const extension = input.toLowerCase().endsWith(".kbm") ? ".kbm" : ".scl";
465
+ if (!input.toLowerCase().endsWith(extension))
466
+ throw new TuningError(`${input} must be a ${extension} file`);
467
+ const absolute = isAbsolute(input) ? input : resolve(cwd, input);
468
+ const inside = relative(projectRoot, absolute);
469
+ if (inside && !inside.startsWith("..") && !isAbsolute(inside))
470
+ return inside.split(sep).join("/");
471
+ let text: string;
472
+ try {
473
+ text = await readFile(absolute, "utf8");
474
+ } catch {
475
+ throw new TuningError(`${input} not found`);
476
+ }
477
+ await mkdir(join(projectRoot, "tunings"), { recursive: true });
478
+ const stem = basename(absolute, extension).replace(/[^A-Za-z0-9._-]+/g, "-");
479
+ for (let index = 1; index < 1_000; index++) {
480
+ const name = `${stem}${index === 1 ? "" : `-${index}`}${extension}`;
481
+ const target = join(projectRoot, "tunings", name);
482
+ const existing = await stat(target).catch(() => undefined);
483
+ if (existing) {
484
+ const same = await readFile(target, "utf8").catch(() => undefined);
485
+ if (same !== text) continue;
486
+ } else await copyFile(absolute, target);
487
+ return `tunings/${name}`;
488
+ }
489
+ throw new TuningError(`too many tunings named ${stem}`);
490
+ }