@hraness/dawg 0.2.0 → 0.4.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 (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Two-way sync between the project files and the session score.
3
+ *
4
+ * files → score: a change to `song.ts` or `tracks/<slug>/track.ts` (fs.watch
5
+ * plus a slow poll, debounced 150 ms) evaluates the project, adopts the
6
+ * session's note ids, diffs against the current score and commits one
7
+ * `files.apply` revision through the session port. A failed evaluation is
8
+ * reported as a card and leaves the score alone.
9
+ *
10
+ * score → files: a new score revision reprints the project and writes only
11
+ * the files whose bytes differ. Files whose last evaluation already equals
12
+ * the score are never rewritten, so an author's own formatting survives.
13
+ *
14
+ * Echo suppression: `.dawg/sync.json` records the hash of every file dawg
15
+ * wrote; a watcher event whose file still hashes the same is ignored, in
16
+ * this window and every other one.
17
+ */
18
+
19
+ import { createHash } from "node:crypto";
20
+ import { watch, type FSWatcher } from "node:fs";
21
+ import { readdir, readFile, rm, stat } from "node:fs/promises";
22
+ import { join } from "node:path";
23
+ import {
24
+ adoptNoteIds,
25
+ applyScoreOperations,
26
+ diffScores,
27
+ } from "../../core/diff.ts";
28
+ import { encodeLoop } from "../../core/loop.ts";
29
+ import type { ScoreOperation, TrackScore } from "../../core/score.ts";
30
+ import {
31
+ evaluateProject,
32
+ formatDiagnostic,
33
+ type Diagnostic,
34
+ type EvalResult,
35
+ } from "../../core/sdk/eval.ts";
36
+ import { printProject } from "../../core/sdk/print.ts";
37
+ import { writeAtomic } from "./init.ts";
38
+ import { typecheckProject, type TypecheckResult } from "./typecheck.ts";
39
+
40
+ export const SYNC_DEBOUNCE_MS = 150;
41
+ export const SYNC_POLL_MS = 1_500;
42
+ const SYNC_STATE_FILE = ".dawg/sync.json";
43
+ const MAX_TRACKED_FILES = 512;
44
+
45
+ /** Header indicator state: `types ✓` or `types ✗ N`. */
46
+ export type TypesState = Readonly<{ ok: boolean; errors: number }>;
47
+
48
+ /** Result of reconciling an evaluated score with the session score. */
49
+ export type FilesApplyPlan = Readonly<{
50
+ /** The session score after the file change, with session note ids kept. */
51
+ next: TrackScore;
52
+ operations: readonly ScoreOperation[];
53
+ }>;
54
+
55
+ /**
56
+ * Pure core of files → score: adopts `current`'s note ids into `evaluated`
57
+ * and returns the operations (and resulting score) that bring `current` to
58
+ * it. Empty `operations` means the files already match the score.
59
+ */
60
+ export function applyFiles(
61
+ current: TrackScore,
62
+ evaluated: TrackScore,
63
+ ): FilesApplyPlan {
64
+ const target = adoptNoteIds(current, evaluated);
65
+ const operations = diffScores(current, target);
66
+ const next =
67
+ operations.length === 0
68
+ ? current
69
+ : applyScoreOperations(current, operations);
70
+ return Object.freeze({ next, operations });
71
+ }
72
+
73
+ /** What the sync needs from its window. */
74
+ export type SyncHost = Readonly<{
75
+ project: string;
76
+ /** Current session score. */
77
+ current(): TrackScore;
78
+ /**
79
+ * Commits the plan as one `files.apply` revision. Resolves with the
80
+ * score the session now holds (it may be ahead after a rebase).
81
+ */
82
+ commit(plan: FilesApplyPlan, summary: string): Promise<TrackScore>;
83
+ card(
84
+ text: string,
85
+ tone: "info" | "success" | "warning" | "error",
86
+ hint?: string,
87
+ ): void;
88
+ types(state: TypesState): void;
89
+ }>;
90
+
91
+ export type SyncOptions = Readonly<{
92
+ evaluate?: (project: string) => Promise<EvalResult>;
93
+ typecheck?: (project: string) => Promise<TypecheckResult>;
94
+ debounceMs?: number;
95
+ pollMs?: number;
96
+ /** Disable fs.watch and polling (tests drive `checkFiles` directly). */
97
+ watch?: boolean;
98
+ }>;
99
+
100
+ export type ProjectSync = Readonly<{
101
+ /** Call when the session score changed (local edit or another window). */
102
+ scoreChanged(score: TrackScore): void;
103
+ /**
104
+ * Evaluate the files now (debounce skipped); resolves when applied with a
105
+ * one-line outcome (`applied from files · 1 note`, `files rejected · …`,
106
+ * `files match the score`), the last one when nothing changed since.
107
+ */
108
+ checkFiles(): Promise<string>;
109
+ /** Reprint now (debounce skipped); resolves when written. */
110
+ flushScore(): Promise<void>;
111
+ stop(): Promise<void>;
112
+ }>;
113
+
114
+ type SyncState = { files: Record<string, string> };
115
+
116
+ export function startProjectSync(
117
+ host: SyncHost,
118
+ options: SyncOptions = {},
119
+ ): ProjectSync {
120
+ const evaluate =
121
+ options.evaluate ?? ((project: string) => evaluateProject(project));
122
+ const typecheck =
123
+ options.typecheck ?? ((project: string) => typecheckProject(project));
124
+ const debounceMs = options.debounceMs ?? SYNC_DEBOUNCE_MS;
125
+ const project = host.project;
126
+ const statePath = join(project, SYNC_STATE_FILE);
127
+ /** Hashes of the project files as of the last successful evaluation. */
128
+ let seen = new Map<string, string>();
129
+ /** Outcome of the last evaluation, returned when the files did not change. */
130
+ let lastOutcome = "files match the score";
131
+ /** Encoded score the files were last known to equal. */
132
+ let filesEqual = "";
133
+ let lastRejection = "";
134
+ let stopped = false;
135
+ let queue: Promise<void> = Promise.resolve();
136
+ let fileTimer: ReturnType<typeof setTimeout> | undefined;
137
+ let scoreTimer: ReturnType<typeof setTimeout> | undefined;
138
+ let pendingScore: TrackScore | undefined;
139
+ let watchers: FSWatcher[] = [];
140
+ let poll: ReturnType<typeof setInterval> | undefined;
141
+
142
+ const enqueue = <T>(task: () => Promise<T>): Promise<T> => {
143
+ const run = queue.then(task, task);
144
+ queue = run.then(
145
+ () => undefined,
146
+ () => undefined,
147
+ );
148
+ return run;
149
+ };
150
+
151
+ const readState = async (): Promise<SyncState> => {
152
+ try {
153
+ const value: unknown = JSON.parse(await readFile(statePath, "utf8"));
154
+ if (typeof value !== "object" || value === null) return { files: {} };
155
+ const files = (value as Record<string, unknown>).files;
156
+ if (typeof files !== "object" || files === null) return { files: {} };
157
+ const out: Record<string, string> = {};
158
+ for (const [path, hash] of Object.entries(
159
+ files as Record<string, unknown>,
160
+ ))
161
+ if (
162
+ typeof hash === "string" &&
163
+ /^[0-9a-f]{64}$/.test(hash) &&
164
+ path.length < 512
165
+ )
166
+ out[path] = hash;
167
+ return { files: out };
168
+ } catch {
169
+ return { files: {} };
170
+ }
171
+ };
172
+ const writeState = (state: SyncState) =>
173
+ writeAtomic(statePath, `${JSON.stringify(state, null, 2)}\n`);
174
+
175
+ /**
176
+ * Evaluates the files. In `files-win` mode (edits) the result is committed;
177
+ * in `score-wins` mode (startup with unchanged files) a difference reprints
178
+ * the files from the score instead.
179
+ */
180
+ const checkFilesNow = async (
181
+ mode: "files-win" | "score-wins" = "files-win",
182
+ ): Promise<string> => {
183
+ if (stopped) return lastOutcome;
184
+ const current = await hashProject(project);
185
+ if (mode === "files-win" && sameHashes(current, seen)) return lastOutcome;
186
+ const result = await evaluate(project);
187
+ if (!result.ok) {
188
+ const text = result.diagnostics.map(formatDiagnostic).join("; ");
189
+ if (text !== lastRejection) {
190
+ lastRejection = text;
191
+ host.card(
192
+ `files rejected · ${firstLine(text)}`,
193
+ "error",
194
+ "fix the file to apply",
195
+ );
196
+ }
197
+ void runTypecheck();
198
+ return (lastOutcome = `files rejected · ${text}`);
199
+ }
200
+ lastRejection = "";
201
+ seen = current;
202
+ const plan = applyFiles(host.current(), result.score);
203
+ if (plan.operations.length === 0) {
204
+ filesEqual = encodeLoop(host.current());
205
+ void runTypecheck();
206
+ return (lastOutcome = "files match the score");
207
+ }
208
+ if (mode === "score-wins") {
209
+ pendingScore = host.current();
210
+ await flushScoreNow();
211
+ void runTypecheck();
212
+ return (lastOutcome = "files reprinted from the score");
213
+ }
214
+ const summary = summarize(plan.operations);
215
+ try {
216
+ const committed = await host.commit(plan, summary);
217
+ filesEqual = encodeLoop(committed);
218
+ host.card(`applied from files · ${summary}`, "success");
219
+ lastOutcome = `applied from files · ${summary}`;
220
+ } catch (error) {
221
+ host.card(
222
+ `files not applied · ${firstLine(error instanceof Error ? error.message : String(error))}`,
223
+ "warning",
224
+ );
225
+ lastOutcome = `files not applied · ${error instanceof Error ? error.message : String(error)}`;
226
+ }
227
+ void runTypecheck();
228
+ return lastOutcome;
229
+ };
230
+
231
+ const flushScoreNow = async (): Promise<void> => {
232
+ if (stopped || !pendingScore) return;
233
+ const score = pendingScore;
234
+ pendingScore = undefined;
235
+ const encoded = encodeLoop(score);
236
+ if (encoded === filesEqual) return;
237
+ const printed = printProject(score);
238
+ const state = await readState();
239
+ const files = { ...state.files };
240
+ const expected = new Set(printed.files.map((file) => file.path));
241
+ const hashes = new Map<string, string>();
242
+ for (const file of printed.files) {
243
+ const hash = sha256(file.text);
244
+ hashes.set(file.path, hash);
245
+ const existing = await readOptional(join(project, file.path));
246
+ if (existing === file.text) continue;
247
+ await writeAtomic(join(project, file.path), file.text);
248
+ files[file.path] = hash;
249
+ }
250
+ // Track files we printed earlier for tracks that no longer exist.
251
+ for (const [path, hash] of Object.entries(state.files)) {
252
+ if (expected.has(path) || !path.startsWith("tracks/")) continue;
253
+ const existing = await readOptional(join(project, path));
254
+ if (existing !== undefined && sha256(existing) === hash)
255
+ await rm(join(project, path), { force: true });
256
+ delete files[path];
257
+ }
258
+ await writeState({ files });
259
+ seen = await hashProject(project);
260
+ filesEqual = encoded;
261
+ };
262
+
263
+ let typecheckRunning = false;
264
+ let typecheckAgain = false;
265
+ const runTypecheck = async (): Promise<void> => {
266
+ if (stopped) return;
267
+ if (typecheckRunning) {
268
+ typecheckAgain = true;
269
+ return;
270
+ }
271
+ typecheckRunning = true;
272
+ try {
273
+ const result = await typecheck(project);
274
+ host.types({ ok: result.ok, errors: result.diagnostics.length });
275
+ } catch {
276
+ host.types({ ok: false, errors: 1 });
277
+ } finally {
278
+ typecheckRunning = false;
279
+ if (typecheckAgain) {
280
+ typecheckAgain = false;
281
+ void runTypecheck();
282
+ }
283
+ }
284
+ };
285
+
286
+ const scheduleFiles = () => {
287
+ if (stopped) return;
288
+ if (fileTimer) clearTimeout(fileTimer);
289
+ fileTimer = setTimeout(() => {
290
+ fileTimer = undefined;
291
+ void enqueue(() => checkFilesNow("files-win"));
292
+ }, debounceMs);
293
+ };
294
+
295
+ if (options.watch !== false) {
296
+ const onEvent = (_event: string, filename: string | Buffer | null) => {
297
+ const name = filename === null ? "" : String(filename);
298
+ if (name && !isProjectSource(name)) return;
299
+ scheduleFiles();
300
+ };
301
+ try {
302
+ watchers.push(watch(project, { persistent: false }, onEvent));
303
+ watchers.push(
304
+ watch(
305
+ join(project, "tracks"),
306
+ { persistent: false, recursive: true },
307
+ onEvent,
308
+ ),
309
+ );
310
+ } catch {
311
+ watchers.forEach((watcher) => watcher.close());
312
+ watchers = [];
313
+ }
314
+ poll = setInterval(
315
+ () => void enqueue(() => checkFilesNow("files-win")),
316
+ options.pollMs ?? SYNC_POLL_MS,
317
+ );
318
+ poll.unref?.();
319
+ }
320
+ // Startup: files edited since dawg last wrote them (or never recorded)
321
+ // win; otherwise the session score wins and reprints what files lack.
322
+ void enqueue(async () => {
323
+ const state = await readState();
324
+ const current = await hashProject(project);
325
+ const edited =
326
+ current.size === 0 ||
327
+ [...current].some(([path, hash]) => state.files[path] !== hash);
328
+ await checkFilesNow(edited ? "files-win" : "score-wins");
329
+ });
330
+
331
+ return Object.freeze({
332
+ scoreChanged(score: TrackScore) {
333
+ if (stopped) return;
334
+ pendingScore = score;
335
+ if (scoreTimer) clearTimeout(scoreTimer);
336
+ scoreTimer = setTimeout(() => {
337
+ scoreTimer = undefined;
338
+ void enqueue(flushScoreNow);
339
+ }, debounceMs);
340
+ },
341
+ checkFiles: () => enqueue(() => checkFilesNow("files-win")),
342
+ flushScore: () => enqueue(flushScoreNow),
343
+ async stop() {
344
+ stopped = true;
345
+ if (fileTimer) clearTimeout(fileTimer);
346
+ if (scoreTimer) clearTimeout(scoreTimer);
347
+ if (poll) clearInterval(poll);
348
+ watchers.forEach((watcher) => watcher.close());
349
+ watchers = [];
350
+ await queue.catch(() => undefined);
351
+ },
352
+ });
353
+ }
354
+
355
+ /** Project-relative source paths: `song.ts` and each track file under `tracks/`. */
356
+ export async function projectSourceFiles(project: string): Promise<string[]> {
357
+ const files = ["song.ts"];
358
+ let dirs: string[] = [];
359
+ try {
360
+ dirs = (await readdir(join(project, "tracks"), { withFileTypes: true }))
361
+ .filter((entry) => entry.isDirectory())
362
+ .map((entry) => entry.name)
363
+ .sort();
364
+ } catch {
365
+ dirs = [];
366
+ }
367
+ for (const dir of dirs.slice(0, MAX_TRACKED_FILES)) {
368
+ const path = `tracks/${dir}/track.ts`;
369
+ try {
370
+ if ((await stat(join(project, path))).isFile()) files.push(path);
371
+ } catch {
372
+ // A directory without a track file (samples only) is not a source.
373
+ }
374
+ }
375
+ return files;
376
+ }
377
+
378
+ function isProjectSource(name: string): boolean {
379
+ return (
380
+ name === "song.ts" || /(^|\/)track\.ts$/.test(name) || !name.includes(".")
381
+ );
382
+ }
383
+
384
+ async function hashProject(project: string): Promise<Map<string, string>> {
385
+ const out = new Map<string, string>();
386
+ for (const path of await projectSourceFiles(project)) {
387
+ const text = await readOptional(join(project, path));
388
+ if (text !== undefined) out.set(path, sha256(text));
389
+ }
390
+ return out;
391
+ }
392
+
393
+ function sameHashes(a: Map<string, string>, b: Map<string, string>): boolean {
394
+ if (a.size !== b.size) return false;
395
+ for (const [path, hash] of a) if (b.get(path) !== hash) return false;
396
+ return true;
397
+ }
398
+
399
+ function sha256(text: string): string {
400
+ return createHash("sha256").update(text).digest("hex");
401
+ }
402
+
403
+ async function readOptional(path: string): Promise<string | undefined> {
404
+ try {
405
+ return await readFile(path, "utf8");
406
+ } catch {
407
+ return undefined;
408
+ }
409
+ }
410
+
411
+ function firstLine(text: string): string {
412
+ const line = text.split("\n")[0] ?? "";
413
+ return line.length > 120 ? `${line.slice(0, 119)}…` : line;
414
+ }
415
+
416
+ /** `2 notes, 1 track` style summary of a plan for the activity card. */
417
+ export function summarize(operations: readonly ScoreOperation[]): string {
418
+ const counts = new Map<string, number>();
419
+ for (const op of operations) {
420
+ const key = op.type.endsWith("Note")
421
+ ? "note"
422
+ : op.type.endsWith("Track")
423
+ ? "track"
424
+ : "setting";
425
+ counts.set(key, (counts.get(key) ?? 0) + 1);
426
+ }
427
+ return [...counts.entries()]
428
+ .map(([key, count]) => `${count} ${key}${count === 1 ? "" : "s"}`)
429
+ .join(", ");
430
+ }
431
+
432
+ export type { Diagnostic };
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Typechecks a project with the TypeScript compiler dawg depends on.
3
+ *
4
+ * TypeScript 7 is a native binary with no JavaScript watch API, so each check
5
+ * is one `tsc --noEmit --incremental` run whose build info lives in
6
+ * `.dawg/tsbuild/`; a warm check on a small project takes tens of
7
+ * milliseconds. Diagnostics come back as `file:line:col message`.
8
+ */
9
+
10
+ import { realpathSync } from "node:fs";
11
+ import { mkdir } from "node:fs/promises";
12
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import type { Diagnostic } from "../../core/sdk/eval.ts";
15
+ import type { CommandRunner } from "../auth/runner.ts";
16
+ import { systemRunner } from "../auth/runner.ts";
17
+
18
+ export const TYPECHECK_TIMEOUT_MS = 60_000;
19
+ const MAX_DIAGNOSTICS = 200;
20
+
21
+ export type TypecheckResult = Readonly<{
22
+ ok: boolean;
23
+ diagnostics: readonly Diagnostic[];
24
+ ms: number;
25
+ }>;
26
+
27
+ export type TypecheckOptions = Readonly<{
28
+ runner?: CommandRunner;
29
+ /** Path of the `tsc` executable; resolved from the dependency by default. */
30
+ tsc?: string;
31
+ }>;
32
+
33
+ /** Native `tsc` from `@typescript/typescript-<platform>-<arch>`, else `tsc` on PATH. */
34
+ export function resolveTsc(
35
+ runner: CommandRunner = systemRunner,
36
+ ): string | undefined {
37
+ const name = `@typescript/typescript-${process.platform}-${process.arch}/package.json`;
38
+ try {
39
+ const url = import.meta.resolve(name);
40
+ return join(dirname(fileURLToPath(url)), "lib", "tsc");
41
+ } catch {
42
+ return runner.which("tsc");
43
+ }
44
+ }
45
+
46
+ /** Runs `tsc` on `<project>/tsconfig.json` and parses its diagnostics. */
47
+ export async function typecheckProject(
48
+ project: string,
49
+ options: TypecheckOptions = {},
50
+ ): Promise<TypecheckResult> {
51
+ const started = performance.now();
52
+ const runner = options.runner ?? systemRunner;
53
+ const tsc = options.tsc ?? resolveTsc(runner);
54
+ if (!tsc)
55
+ return {
56
+ ok: false,
57
+ diagnostics: [{ message: "typescript is not installed (no tsc found)" }],
58
+ ms: elapsed(started),
59
+ };
60
+ const stateDir = join(project, ".dawg", "tsbuild");
61
+ await mkdir(stateDir, { recursive: true });
62
+ const result = await runner.run(
63
+ tsc,
64
+ [
65
+ "--noEmit",
66
+ "--pretty",
67
+ "false",
68
+ "--incremental",
69
+ "--tsBuildInfoFile",
70
+ join(stateDir, "project.tsbuildinfo"),
71
+ "-p",
72
+ join(project, "tsconfig.json"),
73
+ ],
74
+ {
75
+ timeoutMs: TYPECHECK_TIMEOUT_MS,
76
+ maxOutputBytes: 1024 * 1024,
77
+ env: { NO_COLOR: "1" },
78
+ },
79
+ );
80
+ if (result.killed)
81
+ return {
82
+ ok: false,
83
+ diagnostics: [{ message: "typecheck timed out" }],
84
+ ms: elapsed(started),
85
+ };
86
+ const diagnostics = parseTscOutput(
87
+ `${result.stdout}\n${result.stderr}`,
88
+ project,
89
+ );
90
+ if (result.code !== 0 && diagnostics.length === 0)
91
+ diagnostics.push({
92
+ message: `tsc exited with code ${result.code}${
93
+ result.stderr.trim() ? `: ${result.stderr.trim().split("\n")[0]}` : ""
94
+ }`,
95
+ });
96
+ return {
97
+ ok: result.code === 0 && diagnostics.length === 0,
98
+ diagnostics,
99
+ ms: elapsed(started),
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Parses `file(line,col): error TSnnnn: message` lines. tsc prints paths
105
+ * relative to its working directory; they come back project-relative.
106
+ */
107
+ export function parseTscOutput(
108
+ output: string,
109
+ project: string,
110
+ cwd: string = process.cwd(),
111
+ ): Diagnostic[] {
112
+ const out: Diagnostic[] = [];
113
+ const roots = [project];
114
+ try {
115
+ roots.push(realpathSync(project));
116
+ } catch {
117
+ // An unreadable project keeps the literal path.
118
+ }
119
+ const relativize = (file: string): string => {
120
+ const absolute = isAbsolute(file) ? file : resolve(cwd, file);
121
+ for (const root of roots) {
122
+ const rel = relative(root, absolute);
123
+ if (rel && !rel.startsWith("..") && !isAbsolute(rel)) return rel;
124
+ }
125
+ return file;
126
+ };
127
+ for (const raw of output.split("\n")) {
128
+ const line = raw.trimEnd();
129
+ const match = line.match(/^(.*?)\((\d+),(\d+)\): error (TS\d+): (.*)$/);
130
+ if (match) {
131
+ const file = relativize(match[1]!);
132
+ out.push({
133
+ file,
134
+ line: Number(match[2]),
135
+ col: Number(match[3]),
136
+ message: `${match[5]} (${match[4]})`,
137
+ });
138
+ } else {
139
+ const bare = line.match(/^error (TS\d+): (.*)$/);
140
+ if (bare) out.push({ message: `${bare[2]} (${bare[1]})` });
141
+ }
142
+ if (out.length >= MAX_DIAGNOSTICS) break;
143
+ }
144
+ return out;
145
+ }
146
+
147
+ function elapsed(started: number): number {
148
+ return Math.round(performance.now() - started);
149
+ }
package/src/render.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * `dawg render <out.wav>`: renders a session (or a `track.loop/v1` file) to
2
+ * `dawg render <out.wav>`: renders a session, the project files (`song.ts`,
3
+ * in a project with no `--session`), or a `track.loop/v1` file to
3
4
  * a stereo 16-bit PCM WAV through the same deterministic renderer playback
4
5
  * uses, so two renders of one score are byte-identical. It reads the session
5
6
  * record from disk and never starts dawgd or plays audio.
@@ -10,6 +11,16 @@ import { resolve } from "node:path";
10
11
  import { scoreFromJSON, type TrackScore } from "../core/score.ts";
11
12
  import { decodeLoop } from "../core/loop.ts";
12
13
  import { renderScoreWav } from "./audio/wav.ts";
14
+ import { SampleLibrary, hasSamplerTracks } from "./audio/samples.ts";
15
+ import {
16
+ PackStore,
17
+ creditsLine,
18
+ packCredits,
19
+ withWavComment,
20
+ writeCredits,
21
+ } from "./audio/packs.ts";
22
+ import { evaluateProject, formatDiagnostic } from "../core/sdk/eval.ts";
23
+ import { isProject } from "./project/init.ts";
13
24
  import { resolveSessionArg } from "./session/attach.ts";
14
25
  import {
15
26
  loadSession,
@@ -31,6 +42,10 @@ export async function runRenderCommand(
31
42
  stderr: Output,
32
43
  ): Promise<number> {
33
44
  const rest = argv.slice(1);
45
+ if (rest.includes("--help") || rest.includes("-h")) {
46
+ stdout.write(`${RENDER_USAGE}\n`);
47
+ return 0;
48
+ }
34
49
  const options = new Map<string, string>();
35
50
  const positional: string[] = [];
36
51
  for (let index = 0; index < rest.length; index += 1) {
@@ -38,13 +53,13 @@ export async function runRenderCommand(
38
53
  if (arg === "--session" || arg === "--import") {
39
54
  const value = rest[index + 1];
40
55
  if (value === undefined || value.startsWith("--")) {
41
- stderr.write(`${arg} needs a value\n${RENDER_USAGE}\n`);
56
+ stderr.write(`${arg} needs a value · ${RENDER_USAGE}\n`);
42
57
  return 2;
43
58
  }
44
59
  options.set(arg, value);
45
60
  index += 1;
46
61
  } else if (arg.startsWith("--")) {
47
- stderr.write(`unknown option ${arg}\n${RENDER_USAGE}\n`);
62
+ stderr.write(`unknown option · ${arg} · ${RENDER_USAGE}\n`);
48
63
  return 2;
49
64
  } else positional.push(arg);
50
65
  }
@@ -62,13 +77,33 @@ export async function runRenderCommand(
62
77
  );
63
78
  return 1;
64
79
  }
65
- const wav = renderScoreWav(score);
80
+ let samples;
81
+ if (hasSamplerTracks(score)) {
82
+ samples = await new SampleLibrary({ projectRoot: workspace }).load(score);
83
+ for (const problem of samples.problems)
84
+ stderr.write(`sample ${problem.level} · ${problem.message}\n`);
85
+ }
86
+ let wav = renderScoreWav(score, { samples });
87
+ // Pack sounds: name the packs (and CC-BY attributions) in the WAV's INFO
88
+ // comment and on stdout; CREDITS.md in a project keeps the attributions.
89
+ const refs = score.tracks.flatMap((track) =>
90
+ Object.values(track.sampler?.voices ?? {}),
91
+ );
92
+ let credits: string | undefined;
93
+ if (refs.some((ref) => ref.src.startsWith("pack:"))) {
94
+ const list = packCredits(refs, await new PackStore().list());
95
+ credits = creditsLine(list);
96
+ if (credits) wav = withWavComment(wav, `samples: ${credits}`);
97
+ if (await isProject(workspace).catch(() => false))
98
+ await writeCredits(workspace, list).catch(() => undefined);
99
+ }
66
100
  const path = resolve(workspace, target);
67
101
  const temporary = `${path}.${process.pid}.tmp`;
68
102
  await writeFile(temporary, wav);
69
103
  await rename(temporary, path);
70
104
  const sha = createHash("sha256").update(wav).digest("hex");
71
105
  stdout.write(`rendered · ${target} · ${wav.byteLength} bytes · ${sha}\n`);
106
+ if (credits) stdout.write(`credits · ${credits}\n`);
72
107
  return 0;
73
108
  }
74
109
 
@@ -84,16 +119,24 @@ async function loadScore(
84
119
  return decodeLoop(contents.toString("utf8"));
85
120
  }
86
121
  const query = options.get("--session");
122
+ if (query === undefined && (await isProject(workspace))) {
123
+ const evaluated = await evaluateProject(workspace);
124
+ if (!evaluated.ok)
125
+ throw new Error(
126
+ `${evaluated.diagnostics.map(formatDiagnostic).join("; ").slice(0, 400)} · fix song.ts and retry (dawg check)`,
127
+ );
128
+ return evaluated.score;
129
+ }
87
130
  const sessionId =
88
131
  query === undefined
89
132
  ? await readCurrentSessionId(workspace)
90
133
  : await resolveSessionArg(workspace, query);
91
134
  if (sessionId === undefined)
92
- throw new Error("no session here; run `dawg` first or pass --import");
135
+ throw new Error("no session here · run dawg first or pass --import");
93
136
  const record = await loadSession<unknown>(
94
137
  sessionPaths(workspace, sessionId),
95
138
  ).catch(() => {
96
- throw new Error(`no session named "${query ?? sessionId}"`);
139
+ throw new Error(`no session named "${query ?? sessionId}" · dawg sessions`);
97
140
  });
98
141
  return scoreFromJSON(record.composition);
99
142
  }