@hraness/dawg 0.2.0 → 0.3.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 (94) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/DAWG.md +196 -11
  3. package/README.md +79 -30
  4. package/core/diff.ts +249 -0
  5. package/core/key.ts +43 -0
  6. package/core/pitch.ts +60 -0
  7. package/core/score.ts +324 -1
  8. package/core/sdk/eval-child.ts +113 -0
  9. package/core/sdk/eval.ts +257 -0
  10. package/core/sdk/print.ts +393 -0
  11. package/core/sdk/v1.ts +954 -0
  12. package/core/slug.ts +19 -0
  13. package/package.json +8 -5
  14. package/src/agent/agent.ts +280 -14
  15. package/src/agent/brief.ts +23 -3
  16. package/src/agent/gateway.ts +215 -41
  17. package/src/agent/models.ts +633 -0
  18. package/src/agent/ops.ts +3 -18
  19. package/src/agent/planner.ts +38 -0
  20. package/src/agent/provider.ts +234 -106
  21. package/src/agent/sse.ts +31 -7
  22. package/src/agent/tools.ts +398 -1
  23. package/src/agent/usage.ts +296 -0
  24. package/src/agent/workspace.ts +683 -0
  25. package/src/agent/xcb-agent.ts +27 -12
  26. package/src/agent/xcb.ts +233 -21
  27. package/src/audio/click.ts +125 -0
  28. package/src/audio/engine.ts +407 -33
  29. package/src/audio/live.ts +152 -0
  30. package/src/audio/player.ts +19 -4
  31. package/src/audio/render-worker.ts +68 -0
  32. package/src/audio/renderer.ts +174 -0
  33. package/src/audio/sampler.ts +292 -0
  34. package/src/audio/samples.ts +683 -0
  35. package/src/audio/wav.ts +290 -76
  36. package/src/auth/cli.ts +146 -27
  37. package/src/auth/credentials.ts +167 -41
  38. package/src/auth/discover.ts +481 -0
  39. package/src/auth/login.ts +885 -128
  40. package/src/auth/openrouter.ts +206 -0
  41. package/src/auth/picker.ts +282 -0
  42. package/src/auth/runner.ts +25 -2
  43. package/src/auth/tui.ts +60 -43
  44. package/src/commands/edit.ts +170 -0
  45. package/src/commands/help.ts +247 -0
  46. package/src/commands/history.ts +32 -19
  47. package/src/commands/music.ts +24 -7
  48. package/src/commands/sample.ts +302 -0
  49. package/src/main.ts +1043 -166
  50. package/src/media/analyze.ts +364 -0
  51. package/src/media/backend.ts +253 -0
  52. package/src/media/cli.ts +173 -0
  53. package/src/media/download.ts +281 -0
  54. package/src/media/dsp.ts +281 -0
  55. package/src/media/import.ts +130 -0
  56. package/src/media/lyrics.ts +201 -0
  57. package/src/media/notes.ts +363 -0
  58. package/src/media/paths.ts +168 -0
  59. package/src/media/process.ts +226 -0
  60. package/src/media/registry.ts +9 -0
  61. package/src/media/sidecar.ts +72 -0
  62. package/src/media/stemdeck.ts +254 -0
  63. package/src/media/stems.ts +173 -0
  64. package/src/media/tools.ts +292 -0
  65. package/src/media/types.ts +92 -0
  66. package/src/media/vendor/basic-pitch.ts +261 -0
  67. package/src/media/vendor/drums.ts +817 -0
  68. package/src/media/vendor/grid.ts +203 -0
  69. package/src/media/vendor/util.ts +139 -0
  70. package/src/media/vendor/wav.ts +233 -0
  71. package/src/project/check.ts +80 -0
  72. package/src/project/init.ts +253 -0
  73. package/src/project/sync.ts +432 -0
  74. package/src/project/typecheck.ts +149 -0
  75. package/src/render.ts +28 -6
  76. package/src/session/attach.ts +3 -4
  77. package/src/session/daemon.ts +25 -8
  78. package/src/session/delta.ts +249 -0
  79. package/src/session/naming.ts +3 -37
  80. package/src/session/port.ts +26 -6
  81. package/src/session/rebase.ts +38 -8
  82. package/src/session/store.ts +116 -21
  83. package/src/tui/menu.ts +1083 -0
  84. package/src/tui/play-mode.ts +442 -0
  85. package/src/tui/play-session.ts +636 -0
  86. package/src/web/fetch.ts +340 -0
  87. package/src/web/http.ts +137 -0
  88. package/src/web/search.ts +681 -0
  89. package/tui/activity.ts +42 -3
  90. package/tui/app.ts +261 -18
  91. package/tui/drums.ts +44 -0
  92. package/tui/highway.ts +18 -2
  93. package/tui/layers.ts +14 -2
  94. package/tui/play-strip.ts +143 -0
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Evaluates a project's `song.ts` in a sandboxed Bun subprocess and parses
3
+ * the result from `unknown` into a validated `TrackScore`.
4
+ *
5
+ * The child runs `eval-child.ts` with a scrubbed environment, the project as
6
+ * its working directory, no native addons or auto-install, a 10 second
7
+ * budget and a 1 MiB output bound. Anything it prints that is not the
8
+ * expected JSON line is a diagnostic.
9
+ */
10
+
11
+ import { dirname, join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { decodeLoopDocument } from "../loop.ts";
14
+ import { ScoreValidationError, TrackScore } from "../score.ts";
15
+
16
+ export const EVAL_TIMEOUT_MS = 10_000;
17
+ export const EVAL_MAX_OUTPUT_BYTES = 1024 * 1024;
18
+
19
+ /** A problem in a project file; `file` is project-relative when known. */
20
+ export type Diagnostic = Readonly<{
21
+ message: string;
22
+ file?: string;
23
+ line?: number;
24
+ col?: number;
25
+ }>;
26
+
27
+ export type EvalResult =
28
+ | Readonly<{ ok: true; score: TrackScore; ms: number }>
29
+ | Readonly<{ ok: false; diagnostics: readonly Diagnostic[]; ms: number }>;
30
+
31
+ /** How the evaluator starts its child; tests inject a scripted one. */
32
+ export type EvalSpawn = (
33
+ args: readonly string[],
34
+ options: Readonly<{
35
+ cwd: string;
36
+ env: Readonly<Record<string, string>>;
37
+ timeoutMs: number;
38
+ maxOutputBytes: number;
39
+ }>,
40
+ ) => Promise<
41
+ Readonly<{ code: number; stdout: string; stderr: string; killed: boolean }>
42
+ >;
43
+
44
+ export type EvalOptions = Readonly<{
45
+ spawn?: EvalSpawn;
46
+ timeoutMs?: number;
47
+ }>;
48
+
49
+ const CHILD = join(dirname(fileURLToPath(import.meta.url)), "eval-child.ts");
50
+
51
+ /** Evaluates `<project>/song.ts`; never throws for project errors. */
52
+ export async function evaluateProject(
53
+ project: string,
54
+ options: EvalOptions = {},
55
+ ): Promise<EvalResult> {
56
+ const started = performance.now();
57
+ const spawn = options.spawn ?? bunSpawn;
58
+ const env: Record<string, string> = {};
59
+ for (const key of ["PATH", "HOME", "TMPDIR"]) {
60
+ const value = process.env[key];
61
+ if (value !== undefined) env[key] = value;
62
+ }
63
+ env.NO_COLOR = "1";
64
+ const done = (result: EvalResult): EvalResult => result;
65
+ let run: Awaited<ReturnType<EvalSpawn>>;
66
+ try {
67
+ run = await spawn(
68
+ [
69
+ process.execPath,
70
+ "--no-addons",
71
+ "--no-install",
72
+ "--smol",
73
+ CHILD,
74
+ project,
75
+ ],
76
+ {
77
+ cwd: project,
78
+ env,
79
+ timeoutMs: options.timeoutMs ?? EVAL_TIMEOUT_MS,
80
+ maxOutputBytes: EVAL_MAX_OUTPUT_BYTES,
81
+ },
82
+ );
83
+ } catch (error) {
84
+ return done(
85
+ failure(started, [{ message: `could not start bun: ${text(error)}` }]),
86
+ );
87
+ }
88
+ if (run.killed)
89
+ return done(
90
+ failure(started, [
91
+ {
92
+ file: "song.ts",
93
+ message: "evaluation timed out (10 s); song.ts must be pure",
94
+ },
95
+ ]),
96
+ );
97
+ const line = lastJsonLine(run.stdout);
98
+ if (line === undefined) {
99
+ const detail = (run.stderr || run.stdout)
100
+ .trim()
101
+ .split("\n")
102
+ .slice(0, 8)
103
+ .join("\n");
104
+ return done(
105
+ failure(started, [
106
+ {
107
+ file: "song.ts",
108
+ message: detail
109
+ ? `evaluation exited with code ${run.code}: ${detail}`
110
+ : `evaluation exited with code ${run.code}`,
111
+ },
112
+ ]),
113
+ );
114
+ }
115
+ return done(parseChildOutput(line, started));
116
+ }
117
+
118
+ /** Parses the child's JSON line from `unknown`; exported for tests. */
119
+ export function parseChildOutput(
120
+ line: string,
121
+ started = performance.now(),
122
+ ): EvalResult {
123
+ let value: unknown;
124
+ try {
125
+ value = JSON.parse(line);
126
+ } catch {
127
+ return failure(started, [
128
+ { file: "song.ts", message: "evaluation printed invalid JSON" },
129
+ ]);
130
+ }
131
+ if (typeof value !== "object" || value === null)
132
+ return failure(started, [
133
+ { file: "song.ts", message: "evaluation printed no result" },
134
+ ]);
135
+ const record = value as Record<string, unknown>;
136
+ if (record.ok !== true) {
137
+ const error =
138
+ typeof record.error === "object" && record.error !== null
139
+ ? (record.error as Record<string, unknown>)
140
+ : {};
141
+ const diagnostic: {
142
+ message: string;
143
+ file?: string;
144
+ line?: number;
145
+ col?: number;
146
+ } = {
147
+ message:
148
+ typeof error.message === "string" && error.message.length > 0
149
+ ? error.message.slice(0, 2000)
150
+ : "song.ts threw",
151
+ };
152
+ if (typeof error.file === "string")
153
+ diagnostic.file = error.file.slice(0, 512);
154
+ if (typeof error.line === "number" && Number.isInteger(error.line))
155
+ diagnostic.line = error.line;
156
+ if (typeof error.col === "number" && Number.isInteger(error.col))
157
+ diagnostic.col = error.col;
158
+ return failure(started, [diagnostic]);
159
+ }
160
+ try {
161
+ const score = decodeLoopDocument(record.score);
162
+ return { ok: true, score, ms: elapsed(started) };
163
+ } catch (error) {
164
+ return failure(started, [
165
+ {
166
+ file: "song.ts",
167
+ message:
168
+ error instanceof ScoreValidationError
169
+ ? `invalid score: ${error.message}`
170
+ : `invalid score: ${text(error)}`,
171
+ },
172
+ ]);
173
+ }
174
+ }
175
+
176
+ /** `file:line:col message` for humans; file-less diagnostics print the message alone. */
177
+ export function formatDiagnostic(diagnostic: Diagnostic): string {
178
+ if (!diagnostic.file) return diagnostic.message;
179
+ const where =
180
+ diagnostic.line === undefined
181
+ ? diagnostic.file
182
+ : `${diagnostic.file}:${diagnostic.line}:${diagnostic.col ?? 1}`;
183
+ return `${where} ${diagnostic.message}`;
184
+ }
185
+
186
+ function failure(
187
+ started: number,
188
+ diagnostics: readonly Diagnostic[],
189
+ ): EvalResult {
190
+ return {
191
+ ok: false,
192
+ diagnostics: Object.freeze(diagnostics.map((d) => Object.freeze({ ...d }))),
193
+ ms: elapsed(started),
194
+ };
195
+ }
196
+
197
+ function elapsed(started: number): number {
198
+ return Math.round(performance.now() - started);
199
+ }
200
+
201
+ function text(error: unknown): string {
202
+ return error instanceof Error ? error.message : String(error);
203
+ }
204
+
205
+ function lastJsonLine(stdout: string): string | undefined {
206
+ const lines = stdout.split("\n").map((line) => line.trim());
207
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
208
+ const line = lines[index]!;
209
+ if (line.startsWith('{"ok":')) return line;
210
+ }
211
+ return undefined;
212
+ }
213
+
214
+ /** Default spawner: `Bun.spawn` with the given env only (no inheritance). */
215
+ export const bunSpawn: EvalSpawn = async (args, options) => {
216
+ const child = Bun.spawn([...args], {
217
+ cwd: options.cwd,
218
+ env: options.env,
219
+ stdin: "ignore",
220
+ stdout: "pipe",
221
+ stderr: "pipe",
222
+ });
223
+ let killed = false;
224
+ const timer = setTimeout(() => {
225
+ killed = true;
226
+ child.kill();
227
+ }, options.timeoutMs);
228
+ const read = async (stream: ReadableStream<Uint8Array> | null) => {
229
+ if (!stream) return "";
230
+ const chunks: Uint8Array[] = [];
231
+ let total = 0;
232
+ const reader = stream.getReader();
233
+ for (;;) {
234
+ const { value, done } = await reader.read();
235
+ if (done || !value) break;
236
+ if (total + value.byteLength > options.maxOutputBytes) {
237
+ killed = true;
238
+ child.kill();
239
+ break;
240
+ }
241
+ chunks.push(value);
242
+ total += value.byteLength;
243
+ }
244
+ reader.releaseLock();
245
+ return Buffer.concat(chunks).toString("utf8");
246
+ };
247
+ try {
248
+ const [stdout, stderr, code] = await Promise.all([
249
+ read(child.stdout),
250
+ read(child.stderr),
251
+ child.exited,
252
+ ]);
253
+ return { code, stdout, stderr, killed };
254
+ } finally {
255
+ clearTimeout(timer);
256
+ }
257
+ };
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Deterministic printer: score → `song.ts` + `tracks/<slug>/track.ts`.
3
+ *
4
+ * Output is already in prettier's shape (80 columns, double quotes, trailing
5
+ * commas, two-space indent, objects kept expanded) so formatting it again is
6
+ * a no-op, and printing the evaluation of a printed project reproduces the
7
+ * same bytes. Note ids are never printed: `song()` derives them from content.
8
+ */
9
+
10
+ import { DRUM_VOICES, isDrumInstrument } from "../drums.ts";
11
+ import { midiToPitch } from "../pitch.ts";
12
+ import {
13
+ isSamplerInstrument,
14
+ samplerVoiceSlots,
15
+ TrackScore,
16
+ type AutomationPoint,
17
+ type Note,
18
+ type SampleRef,
19
+ type Sampler,
20
+ type Track,
21
+ } from "../score.ts";
22
+ import { trackSlug } from "../slug.ts";
23
+ import { DEFAULT_HIT_LENGTH, DEFAULT_VELOCITY } from "./v1.ts";
24
+
25
+ const WIDTH = 80;
26
+ const INDENT = " ";
27
+
28
+ /** One file the printer produces, path relative to the project root. */
29
+ export type PrintedFile = Readonly<{ path: string; text: string }>;
30
+
31
+ /** Everything a project needs for a score: `song.ts` first, then one file per track in score order. */
32
+ export type PrintedProject = Readonly<{
33
+ files: readonly PrintedFile[];
34
+ /** Track id → `tracks/<dir>/track.ts`. */
35
+ trackFiles: ReadonlyMap<string, string>;
36
+ }>;
37
+
38
+ /** Directory under `tracks/` for each track, unique in score order. */
39
+ export function trackDirectories(
40
+ score: TrackScore,
41
+ ): ReadonlyMap<string, string> {
42
+ const used = new Set<string>();
43
+ const out = new Map<string, string>();
44
+ for (const track of score.tracks) {
45
+ const base = trackSlug(track.name);
46
+ let dir = base;
47
+ for (let n = 2; used.has(dir); n += 1) dir = `${base}-${n}`;
48
+ used.add(dir);
49
+ out.set(track.id, dir);
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /** Prints the whole project. Pure; returns frozen data. */
55
+ export function printProject(score: TrackScore): PrintedProject {
56
+ const dirs = trackDirectories(score);
57
+ const trackFiles = new Map<string, string>();
58
+ const files: PrintedFile[] = [
59
+ Object.freeze({ path: "song.ts", text: printSong(score, dirs) }),
60
+ ];
61
+ for (const track of score.tracks) {
62
+ const path = `tracks/${dirs.get(track.id)!}/track.ts`;
63
+ trackFiles.set(track.id, path);
64
+ files.push(Object.freeze({ path, text: printTrack(score, track) }));
65
+ }
66
+ return Object.freeze({ files: Object.freeze(files), trackFiles });
67
+ }
68
+
69
+ const RESERVED = new Set([
70
+ "break",
71
+ "case",
72
+ "catch",
73
+ "class",
74
+ "const",
75
+ "continue",
76
+ "debugger",
77
+ "default",
78
+ "delete",
79
+ "do",
80
+ "else",
81
+ "enum",
82
+ "export",
83
+ "extends",
84
+ "false",
85
+ "finally",
86
+ "for",
87
+ "function",
88
+ "if",
89
+ "import",
90
+ "in",
91
+ "instanceof",
92
+ "new",
93
+ "null",
94
+ "return",
95
+ "super",
96
+ "switch",
97
+ "this",
98
+ "throw",
99
+ "true",
100
+ "try",
101
+ "typeof",
102
+ "var",
103
+ "void",
104
+ "while",
105
+ "with",
106
+ "yield",
107
+ "let",
108
+ "static",
109
+ "implements",
110
+ "interface",
111
+ "package",
112
+ "private",
113
+ "protected",
114
+ "public",
115
+ "await",
116
+ "song",
117
+ "track",
118
+ "note",
119
+ "seq",
120
+ "hit",
121
+ "hits",
122
+ "every",
123
+ "sampler",
124
+ "slices",
125
+ ]);
126
+
127
+ /** Import identifiers for each track, unique in score order. */
128
+ export function trackIdentifiers(
129
+ score: TrackScore,
130
+ dirs: ReadonlyMap<string, string> = trackDirectories(score),
131
+ ): ReadonlyMap<string, string> {
132
+ const used = new Set<string>();
133
+ const out = new Map<string, string>();
134
+ for (const track of score.tracks) {
135
+ let base = dirs.get(track.id)!.replace(/-/g, "_");
136
+ if (!/^[a-z_]/.test(base) || RESERVED.has(base)) base = `track_${base}`;
137
+ let name = base;
138
+ for (let n = 2; used.has(name); n += 1) name = `${base}_${n}`;
139
+ used.add(name);
140
+ out.set(track.id, name);
141
+ }
142
+ return out;
143
+ }
144
+
145
+ /** `song.ts` for a score. */
146
+ export function printSong(
147
+ score: TrackScore,
148
+ dirs: ReadonlyMap<string, string> = trackDirectories(score),
149
+ ): string {
150
+ const ids = trackIdentifiers(score, dirs);
151
+ const lines: string[] = ['import { song } from "dawg";'];
152
+ for (const track of score.tracks)
153
+ lines.push(
154
+ `import ${ids.get(track.id)!} from "./tracks/${dirs.get(track.id)!}/track.ts";`,
155
+ );
156
+ lines.push("");
157
+ const entries: string[] = [
158
+ `tempo: ${num(score.tempoBpm)}`,
159
+ `meter: [${num(score.beatsPerBar)}, 4]`,
160
+ `bars: ${num(score.bars)}`,
161
+ ];
162
+ if (score.key !== null) entries.push(`key: ${str(score.key)}`);
163
+ if (score.ticksPerBeat !== 480)
164
+ entries.push(`ticksPerBeat: ${num(score.ticksPerBeat)}`);
165
+ entries.push(
166
+ `tracks: ${list(
167
+ score.tracks.map((track) => ids.get(track.id)!),
168
+ INDENT,
169
+ "tracks: ".length,
170
+ 1,
171
+ )}`,
172
+ );
173
+ lines.push("export default song({");
174
+ for (const entry of entries) lines.push(`${INDENT}${entry},`);
175
+ lines.push("});", "");
176
+ return lines.join("\n");
177
+ }
178
+
179
+ /** `tracks/<slug>/track.ts` for one track of a score. */
180
+ export function printTrack(score: TrackScore, track: Track): string {
181
+ const notes = score.notes.filter((note) => note.trackId === track.id);
182
+ const kit = isDrumInstrument(track.instrument);
183
+ const voiceSlots =
184
+ isSamplerInstrument(track.instrument) && track.sampler
185
+ ? samplerVoiceSlots(track.sampler)
186
+ : undefined;
187
+ // Keyed samplers have no slots: their notes print as named pitches.
188
+ const slots = voiceSlots && voiceSlots.size > 0 ? voiceSlots : undefined;
189
+ const voiceFor = new Map<number, string>();
190
+ if (kit) for (const info of DRUM_VOICES) voiceFor.set(info.pitch, info.voice);
191
+ if (slots) for (const [voice, slot] of slots) voiceFor.set(slot, voice);
192
+ const used = new Set<string>(["track"]);
193
+ const printed = notes.map((note) => {
194
+ const voice = voiceFor.get(note.pitch);
195
+ if (voice !== undefined) {
196
+ used.add("hit");
197
+ return printHit(score, note, voice);
198
+ }
199
+ used.add("note");
200
+ return printNote(score, note, !kit && !slots);
201
+ });
202
+ if (track.sampler) used.add("sampler");
203
+
204
+ const entries: string[] = [
205
+ `id: ${str(track.id)}`,
206
+ `name: ${str(track.name)}`,
207
+ ];
208
+ if (track.sampler) {
209
+ entries.push(`instrument: ${printSampler(track.sampler, INDENT)}`);
210
+ } else entries.push(`instrument: ${str(track.instrument)}`);
211
+ if (track.muted) entries.push("muted: true");
212
+ if (track.solo) entries.push("solo: true");
213
+ if (track.volume !== 1) entries.push(`volume: ${num(track.volume)}`);
214
+ if (track.pan !== 0) entries.push(`pan: ${num(track.pan)}`);
215
+ if (track.filter)
216
+ entries.push(
217
+ `filter: ${obj(
218
+ [
219
+ ["cutoff", num(track.filter.cutoff)],
220
+ ["resonance", num(track.filter.resonance)],
221
+ ],
222
+ INDENT,
223
+ "filter: ".length,
224
+ 1,
225
+ )}`,
226
+ );
227
+ if (track.delay)
228
+ entries.push(
229
+ `delay: ${obj(
230
+ [
231
+ ["beats", num(track.delay.beats)],
232
+ ["feedback", num(track.delay.feedback)],
233
+ ["mix", num(track.delay.mix)],
234
+ ],
235
+ INDENT,
236
+ "delay: ".length,
237
+ 1,
238
+ )}`,
239
+ );
240
+ if (track.reverb)
241
+ entries.push(
242
+ `reverb: ${obj(
243
+ [
244
+ ["mix", num(track.reverb.mix)],
245
+ ["size", num(track.reverb.size)],
246
+ ],
247
+ INDENT,
248
+ "reverb: ".length,
249
+ 1,
250
+ )}`,
251
+ );
252
+ const lanes: [string, readonly AutomationPoint[] | undefined][] = [
253
+ ["volume", track.volumeAutomation],
254
+ ["pan", track.panAutomation],
255
+ ["filter", track.filterAutomation],
256
+ ["resonance", track.resonanceAutomation],
257
+ ["delayFeedback", track.delayFeedbackAutomation],
258
+ ["delayMix", track.delayMixAutomation],
259
+ ];
260
+ const automation = lanes.filter(([, points]) => points && points.length > 0);
261
+ if (automation.length > 0) {
262
+ const inner = INDENT + INDENT;
263
+ const body = automation.map(([lane, points]) => {
264
+ const items = points!.map(
265
+ (point) =>
266
+ `[${num(point.tick / score.ticksPerBeat)}, ${num(point.value)}]`,
267
+ );
268
+ // Prettier forces a break on arrays of 2+ arrays that each hold 2+ items.
269
+ return `${inner}${lane}: ${list(items, inner, `${lane}: `.length, 1, items.length > 1)},`;
270
+ });
271
+ entries.push(`automation: {\n${body.join("\n")}\n${INDENT}}`);
272
+ }
273
+ entries.push(`notes: ${list(printed, INDENT, "notes: ".length, 1)}`);
274
+
275
+ const names = ["track", "note", "seq", "hit", "hits", "every", "sampler"]
276
+ .filter((name) => used.has(name))
277
+ .join(", ");
278
+ const lines = [
279
+ `import { ${names} } from "dawg";`,
280
+ "",
281
+ "export default track({",
282
+ ];
283
+ for (const entry of entries) lines.push(`${INDENT}${entry},`);
284
+ lines.push("});", "");
285
+ return lines.join("\n");
286
+ }
287
+
288
+ function printNote(score: TrackScore, note: Note, named: boolean): string {
289
+ const pitch = named ? str(midiToPitch(note.pitch)) : num(note.pitch);
290
+ const args = [pitch, num(note.startTick / score.ticksPerBeat)];
291
+ const length = note.durationTicks / score.ticksPerBeat;
292
+ if (length !== 1 || note.velocity !== DEFAULT_VELOCITY)
293
+ args.push(num(length));
294
+ if (note.velocity !== DEFAULT_VELOCITY) args.push(num(note.velocity));
295
+ return `note(${args.join(", ")})`;
296
+ }
297
+
298
+ function printHit(score: TrackScore, note: Note, voice: string): string {
299
+ const args = [str(voice), num(note.startTick / score.ticksPerBeat)];
300
+ const length = note.durationTicks / score.ticksPerBeat;
301
+ if (note.velocity !== DEFAULT_VELOCITY || length !== DEFAULT_HIT_LENGTH)
302
+ args.push(num(note.velocity));
303
+ if (length !== DEFAULT_HIT_LENGTH) args.push(num(length));
304
+ return `hit(${args.join(", ")})`;
305
+ }
306
+
307
+ function printSampler(sampler: Sampler, indent: string): string {
308
+ const inner = indent + INDENT;
309
+ const voices = Object.keys(sampler.voices)
310
+ .sort()
311
+ .map((name) => {
312
+ const key = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : str(name);
313
+ return `${inner}${key}: ${printSample(sampler.voices[name]!, inner, `${key}: `.length)},`;
314
+ });
315
+ const body = `{\n${voices.join("\n")}\n${indent}}`;
316
+ if (sampler.mode === "oneshot") return `sampler(${body})`;
317
+ // Prettier breaks every argument out when an earlier one contains a break.
318
+ const shifted = `{\n${voices.map((line) => INDENT + line).join("\n")}\n${inner}}`;
319
+ return `sampler(\n${inner}${shifted},\n${inner}{ mode: ${str(sampler.mode)} },\n${indent})`;
320
+ }
321
+
322
+ function printSample(ref: SampleRef, indent: string, prefix: number): string {
323
+ const entries: [string, string][] = [["src", str(ref.src)]];
324
+ if (ref.root !== undefined)
325
+ entries.push(["root", str(midiToPitch(ref.root))]);
326
+ if (ref.begin !== undefined) entries.push(["begin", num(ref.begin)]);
327
+ if (ref.end !== undefined) entries.push(["end", num(ref.end)]);
328
+ if (ref.gain !== undefined) entries.push(["gain", num(ref.gain)]);
329
+ if (ref.speed !== undefined) entries.push(["speed", num(ref.speed)]);
330
+ if (ref.loop !== undefined) entries.push(["loop", String(ref.loop)]);
331
+ if (ref.choke !== undefined) entries.push(["choke", str(ref.choke)]);
332
+ if (entries.length === 1) return str(ref.src);
333
+ return obj(entries, indent, prefix, 1);
334
+ }
335
+
336
+ /** Object literal: inline when the line fits, expanded otherwise (prettier keeps both). */
337
+ function obj(
338
+ entries: readonly (readonly [string, string])[],
339
+ indent: string,
340
+ prefix: number,
341
+ trailing: number,
342
+ ): string {
343
+ const inline = `{ ${entries.map(([k, v]) => `${k}: ${v}`).join(", ")} }`;
344
+ if (indent.length + prefix + inline.length + trailing <= WIDTH) return inline;
345
+ const inner = indent + INDENT;
346
+ return `{\n${entries.map(([k, v]) => `${inner}${k}: ${v},`).join("\n")}\n${indent}}`;
347
+ }
348
+
349
+ /** Array literal: inline when the line fits (and no forced break), else one item per line. */
350
+ function list(
351
+ items: readonly string[],
352
+ indent: string,
353
+ prefix: number,
354
+ trailing: number,
355
+ forceBreak = false,
356
+ ): string {
357
+ if (items.length === 0) return "[]";
358
+ const inline = `[${items.join(", ")}]`;
359
+ if (
360
+ !forceBreak &&
361
+ !inline.includes("\n") &&
362
+ indent.length + prefix + inline.length + trailing <= WIDTH
363
+ )
364
+ return inline;
365
+ const inner = indent + INDENT;
366
+ return `[\n${items.map((item) => `${inner}${item},`).join("\n")}\n${indent}]`;
367
+ }
368
+
369
+ /** Number the way prettier normalizes literals. */
370
+ export function num(value: number): string {
371
+ if (!Number.isFinite(value)) throw new RangeError(`cannot print ${value}`);
372
+ return String(value)
373
+ .replace(/e\+/, "e")
374
+ .replace(/^(-?)\./, "$10.");
375
+ }
376
+
377
+ /** Double-quoted string unless it holds more `"` than `'`, like prettier. */
378
+ export function str(value: string): string {
379
+ const doubles = (value.match(/"/g) ?? []).length;
380
+ const singles = (value.match(/'/g) ?? []).length;
381
+ const quote = doubles > singles ? "'" : '"';
382
+ const escaped = value
383
+ .replace(/\\/g, "\\\\")
384
+ .replace(new RegExp(quote, "g"), `\\${quote}`)
385
+ .replace(/\n/g, "\\n")
386
+ .replace(/\r/g, "\\r")
387
+ .replace(/\t/g, "\\t")
388
+ .replace(
389
+ /[\x00-\x1f\u2028\u2029]/g,
390
+ (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`,
391
+ );
392
+ return `${quote}${escaped}${quote}`;
393
+ }