@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.
- package/CHANGELOG.md +47 -0
- package/DAWG.md +142 -0
- package/LICENSE +21 -0
- package/README.md +164 -2
- package/core/drums.ts +102 -0
- package/core/loop.ts +78 -0
- package/core/score.ts +1065 -0
- package/package.json +42 -4
- package/src/agent/agent.ts +587 -0
- package/src/agent/brief.ts +140 -0
- package/src/agent/gateway.ts +267 -0
- package/src/agent/ops.ts +172 -0
- package/src/agent/planner.ts +221 -0
- package/src/agent/provider.ts +326 -0
- package/src/agent/sse.ts +90 -0
- package/src/agent/tools.ts +976 -0
- package/src/agent/xcb-agent.ts +247 -0
- package/src/agent/xcb.ts +367 -0
- package/src/audio/clock.ts +68 -0
- package/src/audio/engine.ts +467 -0
- package/src/audio/lock.ts +57 -0
- package/src/audio/player.ts +119 -0
- package/src/audio/wav.ts +647 -0
- package/src/auth/cli.ts +112 -0
- package/src/auth/credentials.ts +285 -0
- package/src/auth/login.ts +434 -0
- package/src/auth/runner.ts +184 -0
- package/src/auth/tui.ts +90 -0
- package/src/commands/history.ts +56 -0
- package/src/commands/music.ts +444 -0
- package/src/daemon.ts +31 -0
- package/src/main.ts +1332 -0
- package/src/render.ts +99 -0
- package/src/session/attach.ts +182 -0
- package/src/session/client.ts +498 -0
- package/src/session/daemon.ts +723 -0
- package/src/session/list.ts +180 -0
- package/src/session/lock.ts +92 -0
- package/src/session/meta.ts +253 -0
- package/src/session/naming.ts +464 -0
- package/src/session/port.ts +461 -0
- package/src/session/presence.ts +159 -0
- package/src/session/protocol.ts +618 -0
- package/src/session/rebase.ts +138 -0
- package/src/session/store.ts +486 -0
- package/tui/activity.ts +325 -0
- package/tui/app.ts +1129 -0
- package/tui/drums.ts +21 -0
- package/tui/highway.ts +905 -0
- package/tui/input.ts +63 -0
- package/tui/keys.ts +102 -0
- package/tui/layers.ts +68 -0
- package/tui/prompt.ts +609 -0
- package/tui/render.ts +124 -0
- package/tui/screen.ts +247 -0
- package/tui/text.ts +72 -0
- 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
|
+
}
|