@hraness/dawg 0.0.0-stage → 0.2.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 (57) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/DAWG.md +142 -0
  3. package/LICENSE +21 -0
  4. package/README.md +164 -2
  5. package/core/drums.ts +102 -0
  6. package/core/loop.ts +78 -0
  7. package/core/score.ts +1065 -0
  8. package/package.json +42 -4
  9. package/src/agent/agent.ts +587 -0
  10. package/src/agent/brief.ts +140 -0
  11. package/src/agent/gateway.ts +267 -0
  12. package/src/agent/ops.ts +172 -0
  13. package/src/agent/planner.ts +221 -0
  14. package/src/agent/provider.ts +326 -0
  15. package/src/agent/sse.ts +90 -0
  16. package/src/agent/tools.ts +976 -0
  17. package/src/agent/xcb-agent.ts +247 -0
  18. package/src/agent/xcb.ts +367 -0
  19. package/src/audio/clock.ts +68 -0
  20. package/src/audio/engine.ts +467 -0
  21. package/src/audio/lock.ts +57 -0
  22. package/src/audio/player.ts +119 -0
  23. package/src/audio/wav.ts +647 -0
  24. package/src/auth/cli.ts +112 -0
  25. package/src/auth/credentials.ts +285 -0
  26. package/src/auth/login.ts +434 -0
  27. package/src/auth/runner.ts +184 -0
  28. package/src/auth/tui.ts +90 -0
  29. package/src/commands/history.ts +56 -0
  30. package/src/commands/music.ts +444 -0
  31. package/src/daemon.ts +31 -0
  32. package/src/main.ts +1332 -0
  33. package/src/render.ts +99 -0
  34. package/src/session/attach.ts +182 -0
  35. package/src/session/client.ts +498 -0
  36. package/src/session/daemon.ts +723 -0
  37. package/src/session/list.ts +180 -0
  38. package/src/session/lock.ts +92 -0
  39. package/src/session/meta.ts +253 -0
  40. package/src/session/naming.ts +464 -0
  41. package/src/session/port.ts +461 -0
  42. package/src/session/presence.ts +159 -0
  43. package/src/session/protocol.ts +618 -0
  44. package/src/session/rebase.ts +138 -0
  45. package/src/session/store.ts +486 -0
  46. package/tui/activity.ts +325 -0
  47. package/tui/app.ts +1129 -0
  48. package/tui/drums.ts +21 -0
  49. package/tui/highway.ts +905 -0
  50. package/tui/input.ts +63 -0
  51. package/tui/keys.ts +102 -0
  52. package/tui/layers.ts +68 -0
  53. package/tui/prompt.ts +609 -0
  54. package/tui/render.ts +124 -0
  55. package/tui/screen.ts +247 -0
  56. package/tui/text.ts +72 -0
  57. package/tui/theme.ts +451 -0
@@ -0,0 +1,467 @@
1
+ import type { TrackScore } from "../../core/score.ts";
2
+ import { PlaybackLock } from "./lock.ts";
3
+ import { LoopPlayer } from "./player.ts";
4
+ import { DEFAULT_SAMPLE_RATE, RENDER_CHANNELS, renderScorePcm } from "./wav.ts";
5
+
6
+ /**
7
+ * How dawgd (or a file-mode window) makes sound.
8
+ *
9
+ * - `ffplay` and `sox` are streaming backends: one long-lived player reads
10
+ * raw interleaved s16le stereo PCM from stdin, and the engine writes the
11
+ * loop into it paced by the wall clock. Edits swap the loop buffer at the
12
+ * current phase, so playback never restarts and stays sample-continuous.
13
+ * - `afplay` is the last fallback: re-render a WAV and restart the one-shot
14
+ * player on every edit (the pre-engine behavior), so edits cause a gap.
15
+ * - `command` is an explicit DAWG_AUDIO_PLAYER override that also reads
16
+ * s16le from stdin; tests use it to record the exact byte stream.
17
+ * - `none` makes no sound; the transport clock still runs.
18
+ */
19
+ export type AudioBackend = "ffplay" | "sox" | "afplay" | "command" | "none";
20
+
21
+ export type AudioBackendInfo = Readonly<{
22
+ backend: AudioBackend;
23
+ /** True for gapless stdin-streaming backends. */
24
+ streaming: boolean;
25
+ /** argv with `{rate}` / `{channels}` already substituted, if streaming. */
26
+ command?: readonly string[];
27
+ /** Human-readable reason the backend was chosen. */
28
+ detail: string;
29
+ }>;
30
+
31
+ type Which = (binary: string) => string | null;
32
+
33
+ export type DetectOptions = Readonly<{
34
+ env?: Readonly<Record<string, string | undefined>>;
35
+ which?: Which;
36
+ platform?: NodeJS.Platform;
37
+ sampleRate?: number;
38
+ }>;
39
+
40
+ /** Streaming argv for the known stdin players. */
41
+ export function streamingCommand(
42
+ backend: "ffplay" | "sox",
43
+ binary: string,
44
+ sampleRate: number,
45
+ ): string[] {
46
+ if (backend === "ffplay")
47
+ return [
48
+ binary,
49
+ "-nodisp",
50
+ "-hide_banner",
51
+ "-loglevel",
52
+ "quiet",
53
+ "-fflags",
54
+ "nobuffer",
55
+ "-probesize",
56
+ "32",
57
+ "-analyzeduration",
58
+ "0",
59
+ "-f",
60
+ "s16le",
61
+ "-ar",
62
+ String(sampleRate),
63
+ "-ch_layout",
64
+ "stereo",
65
+ "-i",
66
+ "-",
67
+ ];
68
+ return [
69
+ binary,
70
+ "-q",
71
+ "-t",
72
+ "raw",
73
+ "-r",
74
+ String(sampleRate),
75
+ "-e",
76
+ "signed-integer",
77
+ "-b",
78
+ "16",
79
+ "-c",
80
+ String(RENDER_CHANNELS),
81
+ "-L",
82
+ "-",
83
+ ];
84
+ }
85
+
86
+ /**
87
+ * Pick the audio backend. Order: DAWG_AUDIO=0 (none), DAWG_AUDIO_PLAYER
88
+ * (explicit stdin command), DAWG_AUDIO_BACKEND (forced name), then ffplay,
89
+ * sox `play`, and afplay (macOS) in that order.
90
+ */
91
+ export function detectAudioBackend(
92
+ options: DetectOptions = {},
93
+ ): AudioBackendInfo {
94
+ const env = options.env ?? process.env;
95
+ const which: Which = options.which ?? ((binary) => Bun.which(binary));
96
+ const platform = options.platform ?? process.platform;
97
+ const sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;
98
+ if (env.DAWG_AUDIO === "0")
99
+ return { backend: "none", streaming: false, detail: "DAWG_AUDIO=0" };
100
+ const custom = env.DAWG_AUDIO_PLAYER?.trim();
101
+ if (custom) {
102
+ const command = parseCommand(custom).map((part) =>
103
+ part
104
+ .replaceAll("{rate}", String(sampleRate))
105
+ .replaceAll("{channels}", String(RENDER_CHANNELS)),
106
+ );
107
+ if (command.length > 0)
108
+ return {
109
+ backend: "command",
110
+ streaming: true,
111
+ command,
112
+ detail: "DAWG_AUDIO_PLAYER",
113
+ };
114
+ }
115
+ const forced = env.DAWG_AUDIO_BACKEND?.trim().toLowerCase();
116
+ const candidates: AudioBackend[] =
117
+ forced === "ffplay" ||
118
+ forced === "sox" ||
119
+ forced === "afplay" ||
120
+ forced === "none"
121
+ ? [forced]
122
+ : ["ffplay", "sox", "afplay"];
123
+ for (const candidate of candidates) {
124
+ if (candidate === "none")
125
+ return {
126
+ backend: "none",
127
+ streaming: false,
128
+ detail: "DAWG_AUDIO_BACKEND",
129
+ };
130
+ if (candidate === "ffplay" || candidate === "sox") {
131
+ const binary = which(candidate === "ffplay" ? "ffplay" : "play");
132
+ if (!binary) continue;
133
+ return {
134
+ backend: candidate,
135
+ streaming: true,
136
+ command: streamingCommand(candidate, binary, sampleRate),
137
+ detail: `${binary} (gapless stream)`,
138
+ };
139
+ }
140
+ const binary = platform === "darwin" ? which("afplay") : null;
141
+ if (binary)
142
+ return {
143
+ backend: "afplay",
144
+ streaming: false,
145
+ detail: `${binary} (re-render on edit; install ffmpeg or sox for gapless playback)`,
146
+ };
147
+ }
148
+ return {
149
+ backend: "none",
150
+ streaming: false,
151
+ detail: "no player found (install ffmpeg or sox)",
152
+ };
153
+ }
154
+
155
+ /** One status line for `dawg auth status` and the header. */
156
+ export function audioStatusLine(info = detectAudioBackend()): string {
157
+ return `audio: ${info.backend} · ${info.detail}`;
158
+ }
159
+
160
+ /** Splits a command string on whitespace, honoring simple quotes. */
161
+ function parseCommand(value: string): string[] {
162
+ const parts: string[] = [];
163
+ const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g;
164
+ for (const match of value.matchAll(pattern))
165
+ parts.push(match[1] ?? match[2] ?? match[3]!);
166
+ return parts;
167
+ }
168
+
169
+ /** Swaps re-anchor to the transport only past this disagreement. */
170
+ const RESYNC_SECONDS = 0.03;
171
+
172
+ type Sink = {
173
+ write(chunk: Uint8Array): unknown;
174
+ flush?(): unknown;
175
+ end?(): unknown;
176
+ };
177
+
178
+ type PlayerProcess = {
179
+ readonly pid: number;
180
+ readonly stdin: Sink;
181
+ readonly exited: Promise<number>;
182
+ kill(): void;
183
+ };
184
+
185
+ export type AudioEngineOptions = Readonly<{
186
+ lockPath?: string;
187
+ info?: AudioBackendInfo;
188
+ sampleRate?: number;
189
+ /** Frames written ahead of the wall clock. Bounds the player queue. */
190
+ leadMs?: number;
191
+ /** Pump interval. */
192
+ tickMs?: number;
193
+ /** Monotonic milliseconds. Injected by tests to drive the pump. */
194
+ now?: () => number;
195
+ /** Set false to drive `pump()` manually (tests). */
196
+ timer?: boolean;
197
+ spawn?: (command: readonly string[]) => PlayerProcess;
198
+ }>;
199
+
200
+ type Loop = Readonly<{
201
+ pcm: Int16Array;
202
+ frames: number;
203
+ framesPerBeat: number;
204
+ }>;
205
+
206
+ /**
207
+ * The gapless audio engine. It renders the score into one seamless loop
208
+ * (tails folded onto the start) and streams it to a long-lived stdin player.
209
+ *
210
+ * Timing: the engine is paced by the same monotonic clock as the transport.
211
+ * Frame `n` of the stream belongs to wall time `start + n / rate`, and the
212
+ * engine keeps exactly `leadMs` of audio queued ahead of now. The device
213
+ * plays the queue at its own rate, so what you hear trails the transport by
214
+ * the player's fixed output latency and never accumulates drift from
215
+ * re-renders. An edit re-renders and swaps the buffer for every frame not
216
+ * yet written, mapping the current beat into the new loop, so tempo and
217
+ * length changes keep the musical position.
218
+ */
219
+ export class AudioEngine {
220
+ public readonly info: AudioBackendInfo;
221
+ private readonly lock: PlaybackLock;
222
+ private readonly sampleRate: number;
223
+ private readonly leadFrames: number;
224
+ private readonly tickMs: number;
225
+ private readonly now: () => number;
226
+ private readonly useTimer: boolean;
227
+ private readonly spawnPlayer: (command: readonly string[]) => PlayerProcess;
228
+ private fallback: LoopPlayer | undefined;
229
+ private child: PlayerProcess | undefined;
230
+ private timer: ReturnType<typeof setInterval> | undefined;
231
+ private loop: Loop | undefined;
232
+ /** Monotonic ms that stream frame 0 belongs to. */
233
+ private startMs = 0;
234
+ /** Stream frames written since `startMs`. */
235
+ private written = 0;
236
+ /** Loop frame that stream frame `written` reads next (fractional-free). */
237
+ private cursor = 0;
238
+ private starting: Promise<void> | undefined;
239
+ private generation = 0;
240
+ private spawns = 0;
241
+
242
+ public constructor(options: AudioEngineOptions = {}) {
243
+ this.sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;
244
+ this.info =
245
+ options.info ?? detectAudioBackend({ sampleRate: this.sampleRate });
246
+ this.lock = new PlaybackLock(options.lockPath);
247
+ this.leadFrames = Math.round(
248
+ ((options.leadMs ?? 200) * this.sampleRate) / 1000,
249
+ );
250
+ this.tickMs = options.tickMs ?? 20;
251
+ this.now = options.now ?? (() => performance.now());
252
+ this.useTimer = options.timer ?? true;
253
+ this.spawnPlayer = options.spawn ?? spawnStdinPlayer;
254
+ if (this.info.backend === "afplay")
255
+ this.fallback = new LoopPlayer(options.lockPath);
256
+ }
257
+
258
+ /** Whether a streaming player process is currently running. */
259
+ public get streaming(): boolean {
260
+ return this.child !== undefined;
261
+ }
262
+
263
+ /** Player processes started so far (gapless edits never add one). */
264
+ public get playerStarts(): number {
265
+ return this.spawns;
266
+ }
267
+
268
+ /**
269
+ * Start playback at `beat`, or, when already playing, swap in the new
270
+ * score at the current position without restarting the player.
271
+ */
272
+ public async play(score: TrackScore, beat?: number): Promise<void> {
273
+ if (this.info.backend === "none") return;
274
+ if (this.fallback) return this.fallback.play(score, beat);
275
+ const loop = this.render(score);
276
+ if (this.child) {
277
+ this.swap(loop, beat);
278
+ return;
279
+ }
280
+ if (this.starting) {
281
+ await this.starting;
282
+ if (this.child) this.swap(loop, beat);
283
+ return;
284
+ }
285
+ this.starting = this.start(loop, beat ?? 0).finally(() => {
286
+ this.starting = undefined;
287
+ });
288
+ await this.starting;
289
+ }
290
+
291
+ /** Jump to transport `beat` (as of now) without restarting the player. */
292
+ public seek(beat: number): void {
293
+ if (!this.loop || !this.child) return;
294
+ this.cursor = this.frameForBeat(
295
+ this.loop,
296
+ beat + this.queuedBeats(this.loop),
297
+ );
298
+ }
299
+
300
+ /**
301
+ * Beats already queued ahead of the wall clock. The next frame written
302
+ * sounds this far after now, so a transport beat maps to the write head
303
+ * by adding it.
304
+ */
305
+ private queuedBeats(loop: Loop): number {
306
+ const elapsed = Math.floor(
307
+ ((this.now() - this.startMs) * this.sampleRate) / 1000,
308
+ );
309
+ return Math.max(0, this.written - elapsed) / loop.framesPerBeat;
310
+ }
311
+
312
+ public stop(): void {
313
+ void this.stopAsync();
314
+ }
315
+
316
+ public async stopAsync(): Promise<void> {
317
+ this.generation += 1;
318
+ if (this.fallback) {
319
+ this.fallback.stop();
320
+ return;
321
+ }
322
+ await this.starting?.catch(() => undefined);
323
+ if (this.timer) clearInterval(this.timer);
324
+ this.timer = undefined;
325
+ const child = this.child;
326
+ this.child = undefined;
327
+ this.loop = undefined;
328
+ if (child) {
329
+ try {
330
+ child.stdin.end?.();
331
+ } catch {
332
+ /* already closed */
333
+ }
334
+ child.kill();
335
+ await child.exited.catch(() => undefined);
336
+ }
337
+ await this.lock.release();
338
+ }
339
+
340
+ /**
341
+ * Write every frame due by now plus the lead. Called by the timer; tests
342
+ * call it directly with an injected clock.
343
+ */
344
+ public pump(): void {
345
+ const child = this.child;
346
+ const loop = this.loop;
347
+ if (!child || !loop) return;
348
+ const due =
349
+ Math.floor(((this.now() - this.startMs) * this.sampleRate) / 1000) +
350
+ this.leadFrames;
351
+ let remaining = due - this.written;
352
+ if (remaining <= 0) return;
353
+ // Never queue more than one second at once, e.g. after a stalled loop.
354
+ if (remaining > this.sampleRate) {
355
+ const skipped = remaining - this.sampleRate;
356
+ this.cursor = (this.cursor + skipped) % loop.frames;
357
+ this.written += skipped;
358
+ remaining = this.sampleRate;
359
+ }
360
+ const out = new Int16Array(remaining * RENDER_CHANNELS);
361
+ let offset = 0;
362
+ while (offset < remaining) {
363
+ const take = Math.min(remaining - offset, loop.frames - this.cursor);
364
+ out.set(
365
+ loop.pcm.subarray(
366
+ this.cursor * RENDER_CHANNELS,
367
+ (this.cursor + take) * RENDER_CHANNELS,
368
+ ),
369
+ offset * RENDER_CHANNELS,
370
+ );
371
+ offset += take;
372
+ this.cursor = (this.cursor + take) % loop.frames;
373
+ }
374
+ this.written += remaining;
375
+ try {
376
+ child.stdin.write(new Uint8Array(out.buffer));
377
+ child.stdin.flush?.();
378
+ } catch {
379
+ /* the exit handler decides whether to restart */
380
+ }
381
+ }
382
+
383
+ private render(score: TrackScore): Loop {
384
+ const audio = renderScorePcm(score, {
385
+ sampleRate: this.sampleRate,
386
+ loop: true,
387
+ });
388
+ return {
389
+ pcm: audio.pcm,
390
+ frames: audio.frames,
391
+ framesPerBeat: (60 * this.sampleRate) / score.tempoBpm,
392
+ };
393
+ }
394
+
395
+ private frameForBeat(loop: Loop, beat: number): number {
396
+ const frame = Math.round(Math.max(0, beat) * loop.framesPerBeat);
397
+ return ((frame % loop.frames) + loop.frames) % loop.frames;
398
+ }
399
+
400
+ private swap(next: Loop, beat?: number): void {
401
+ const previous = this.loop;
402
+ // Keep the musical position: the same beat inside the new loop.
403
+ let cursor = previous
404
+ ? this.frameForBeat(next, this.cursor / previous.framesPerBeat)
405
+ : 0;
406
+ if (beat !== undefined) {
407
+ // Re-anchor to the transport only when it disagrees audibly (a seek
408
+ // or a tempo change); sub-frame rounding must not click on edits.
409
+ const anchored = this.frameForBeat(next, beat + this.queuedBeats(next));
410
+ const distance = Math.abs(anchored - cursor);
411
+ const wrapped = Math.min(distance, next.frames - distance);
412
+ if (!previous || wrapped > this.sampleRate * RESYNC_SECONDS)
413
+ cursor = anchored;
414
+ }
415
+ this.cursor = cursor;
416
+ this.loop = next;
417
+ }
418
+
419
+ private async start(loop: Loop, beat: number): Promise<void> {
420
+ const generation = this.generation;
421
+ if (!(await this.lock.acquire())) return;
422
+ if (generation !== this.generation) {
423
+ await this.lock.release();
424
+ return;
425
+ }
426
+ let child: PlayerProcess;
427
+ try {
428
+ child = this.spawnPlayer(this.info.command ?? []);
429
+ } catch {
430
+ await this.lock.release();
431
+ return;
432
+ }
433
+ this.spawns += 1;
434
+ this.child = child;
435
+ this.loop = loop;
436
+ this.startMs = this.now();
437
+ this.written = 0;
438
+ this.cursor = this.frameForBeat(loop, beat);
439
+ void child.exited.then(() => {
440
+ if (this.child !== child) return;
441
+ // The player died while we still want sound: stop cleanly rather than
442
+ // spin; the next play() starts a fresh player.
443
+ this.child = undefined;
444
+ this.loop = undefined;
445
+ if (this.timer) clearInterval(this.timer);
446
+ this.timer = undefined;
447
+ void this.lock.release();
448
+ });
449
+ this.pump();
450
+ if (this.useTimer) this.timer = setInterval(() => this.pump(), this.tickMs);
451
+ }
452
+ }
453
+
454
+ function spawnStdinPlayer(command: readonly string[]): PlayerProcess {
455
+ if (command.length === 0) throw new Error("no player command");
456
+ const child = Bun.spawn([...command], {
457
+ stdin: "pipe",
458
+ stdout: "ignore",
459
+ stderr: "ignore",
460
+ });
461
+ return {
462
+ pid: child.pid,
463
+ stdin: child.stdin,
464
+ exited: child.exited,
465
+ kill: () => child.kill(),
466
+ };
467
+ }
@@ -0,0 +1,57 @@
1
+ import { mkdir, rm, stat, utimes } from "node:fs/promises";
2
+
3
+ /** Locks older than this without a heartbeat belong to a crashed player. */
4
+ const STALE_LOCK_MS = 15_000;
5
+ const HEARTBEAT_MS = 5_000;
6
+
7
+ /**
8
+ * The per-session audio lock: one directory that at most one process (a
9
+ * window in file mode, or dawgd) holds while it is making sound. Its mtime
10
+ * is a heartbeat so a crashed owner is reclaimed after STALE_LOCK_MS.
11
+ */
12
+ export class PlaybackLock {
13
+ private owned = false;
14
+ private heartbeat: ReturnType<typeof setInterval> | undefined;
15
+
16
+ public constructor(private readonly path: string | undefined) {}
17
+
18
+ public get held(): boolean {
19
+ return this.path === undefined || this.owned;
20
+ }
21
+
22
+ public async acquire(): Promise<boolean> {
23
+ if (!this.path) return true;
24
+ if (this.owned) return true;
25
+ try {
26
+ await mkdir(this.path);
27
+ await Bun.write(`${this.path}/owner`, `${process.pid}\n`);
28
+ this.owned = true;
29
+ this.heartbeat = setInterval(() => {
30
+ if (!this.path || !this.owned) return;
31
+ void utimes(this.path, new Date(), new Date()).catch(() => undefined);
32
+ }, HEARTBEAT_MS);
33
+ return true;
34
+ } catch (error) {
35
+ if ((error as { code?: string }).code !== "EEXIST") throw error;
36
+ try {
37
+ if (Date.now() - (await stat(this.path)).mtimeMs > STALE_LOCK_MS) {
38
+ await rm(this.path, { recursive: true, force: true });
39
+ return this.acquire();
40
+ }
41
+ } catch {
42
+ /* another window released between stat and retry */
43
+ }
44
+ return false;
45
+ }
46
+ }
47
+
48
+ public async release(): Promise<void> {
49
+ if (!this.owned || !this.path) return;
50
+ this.owned = false;
51
+ if (this.heartbeat) {
52
+ clearInterval(this.heartbeat);
53
+ this.heartbeat = undefined;
54
+ }
55
+ await rm(this.path, { recursive: true, force: true });
56
+ }
57
+ }
@@ -0,0 +1,119 @@
1
+ import { unlink } from "node:fs/promises";
2
+ import type { TrackScore } from "../../core/score.ts";
3
+ import { PlaybackLock } from "./lock.ts";
4
+ import { RENDER_CHANNELS, encodeWav, renderScorePcm } from "./wav.ts";
5
+
6
+ /** Small best-effort local player. The score and transport remain testable without a sound device. */
7
+ export class LoopPlayer {
8
+ private process: { kill: () => void; exited?: Promise<number> } | undefined;
9
+ private file: string | undefined;
10
+ private readonly lock: PlaybackLock;
11
+ private readonly lockPath: string | undefined;
12
+
13
+ public constructor(lockPath?: string) {
14
+ this.lockPath = lockPath;
15
+ this.lock = new PlaybackLock(lockPath);
16
+ }
17
+
18
+ private get ownsLock(): boolean {
19
+ return this.lockPath !== undefined && this.lock.held;
20
+ }
21
+
22
+ /** Whether this process currently owns the session's shared audio lock. */
23
+ public get ownsPlaybackLock(): boolean {
24
+ return this.ownsLock;
25
+ }
26
+
27
+ /**
28
+ * Render one seamless loop (tails folded onto the start), rotated so the
29
+ * file begins at `beat`, then (re)start the one-shot player on it. This is
30
+ * the last-resort backend; streaming players never restart on edits.
31
+ */
32
+ public async play(score: TrackScore, beat = 0): Promise<void> {
33
+ await this.stopAsync();
34
+ if (process.env.DAWG_AUDIO === "0") return;
35
+ if (this.lockPath && !(await this.acquireLock())) return;
36
+ const path = `/tmp/dawg-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.wav`;
37
+ await Bun.write(path, renderLoopWav(score, beat));
38
+ this.file = path;
39
+ this.spawn(path);
40
+ }
41
+
42
+ private spawn(path: string): void {
43
+ const command =
44
+ process.platform === "darwin"
45
+ ? ["afplay", path]
46
+ : [
47
+ "ffplay",
48
+ "-nodisp",
49
+ "-autoexit",
50
+ "-loglevel",
51
+ "quiet",
52
+ "-loop",
53
+ "0",
54
+ path,
55
+ ];
56
+ try {
57
+ const child = Bun.spawn(command, { stdout: "ignore", stderr: "ignore" });
58
+ this.process = child;
59
+ void child.exited.then(() => {
60
+ if (this.process !== child) return;
61
+ this.process = undefined;
62
+ // afplay is one-shot, so restart the same rendered loop while the
63
+ // session still owns the playback lock. Stop clears `this.file` first.
64
+ if (this.file === path && this.ownsLock) {
65
+ this.spawn(path);
66
+ return;
67
+ }
68
+ void this.cleanup().then(() => this.releaseLock());
69
+ });
70
+ } catch {
71
+ void this.cleanup().then(() => this.releaseLock());
72
+ }
73
+ }
74
+
75
+ public stop(): void {
76
+ void this.stopAsync();
77
+ }
78
+
79
+ private async stopAsync(): Promise<void> {
80
+ const child = this.process;
81
+ this.process = undefined;
82
+ child?.kill();
83
+ await child?.exited;
84
+ await this.cleanup();
85
+ await this.releaseLock();
86
+ }
87
+
88
+ private async acquireLock(): Promise<boolean> {
89
+ return this.lock.acquire();
90
+ }
91
+
92
+ private async releaseLock(): Promise<void> {
93
+ await this.lock.release();
94
+ }
95
+
96
+ private async cleanup(): Promise<void> {
97
+ if (!this.file) return;
98
+ const path = this.file;
99
+ this.file = undefined;
100
+ await unlink(path).catch(() => undefined);
101
+ }
102
+ }
103
+
104
+ /** A loop-folded WAV whose first frame is `beat` into the loop. */
105
+ export function renderLoopWav(score: TrackScore, beat = 0): Uint8Array {
106
+ const audio = renderScorePcm(score, { loop: true });
107
+ const framesPerBeat = (60 * audio.sampleRate) / score.tempoBpm;
108
+ const start =
109
+ ((Math.round(Math.max(0, beat) * framesPerBeat) % audio.frames) +
110
+ audio.frames) %
111
+ audio.frames;
112
+ const rotated = new Int16Array(audio.pcm.length);
113
+ rotated.set(audio.pcm.subarray(start * RENDER_CHANNELS));
114
+ rotated.set(
115
+ audio.pcm.subarray(0, start * RENDER_CHANNELS),
116
+ (audio.frames - start) * RENDER_CHANNELS,
117
+ );
118
+ return encodeWav(rotated, audio.sampleRate, RENDER_CHANNELS);
119
+ }