@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.
- package/CHANGELOG.md +79 -0
- package/DAWG.md +196 -11
- package/README.md +79 -30
- package/core/diff.ts +249 -0
- package/core/key.ts +43 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +324 -1
- package/core/sdk/eval-child.ts +113 -0
- package/core/sdk/eval.ts +257 -0
- package/core/sdk/print.ts +393 -0
- package/core/sdk/v1.ts +954 -0
- package/core/slug.ts +19 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +280 -14
- package/src/agent/brief.ts +23 -3
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/planner.ts +38 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +398 -1
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +27 -12
- package/src/agent/xcb.ts +233 -21
- package/src/audio/click.ts +125 -0
- package/src/audio/engine.ts +407 -33
- package/src/audio/live.ts +152 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +292 -0
- package/src/audio/samples.ts +683 -0
- package/src/audio/wav.ts +290 -76
- package/src/auth/cli.ts +146 -27
- package/src/auth/credentials.ts +167 -41
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +885 -128
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +25 -2
- package/src/auth/tui.ts +60 -43
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +24 -7
- package/src/commands/sample.ts +302 -0
- package/src/main.ts +1043 -166
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +173 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +292 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +28 -6
- package/src/session/attach.ts +3 -4
- package/src/session/daemon.ts +25 -8
- package/src/session/delta.ts +249 -0
- package/src/session/naming.ts +3 -37
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/menu.ts +1083 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +636 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +681 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +261 -18
- package/tui/drums.ts +44 -0
- package/tui/highway.ts +18 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +143 -0
package/src/audio/engine.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { TrackScore } from "../../core/score.ts";
|
|
2
2
|
import { PlaybackLock } from "./lock.ts";
|
|
3
3
|
import { LoopPlayer } from "./player.ts";
|
|
4
|
-
import {
|
|
4
|
+
import { LoopRenderer, type LoopRender } from "./renderer.ts";
|
|
5
|
+
import { clickSounds, clicksIn, type ClickLevel } from "./click.ts";
|
|
6
|
+
import type { LiveNotePcm } from "./live.ts";
|
|
7
|
+
import { DEFAULT_SAMPLE_RATE, RENDER_CHANNELS } from "./wav.ts";
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* How dawgd (or a file-mode window) makes sound.
|
|
@@ -195,36 +198,96 @@ export type AudioEngineOptions = Readonly<{
|
|
|
195
198
|
/** Set false to drive `pump()` manually (tests). */
|
|
196
199
|
timer?: boolean;
|
|
197
200
|
spawn?: (command: readonly string[]) => PlayerProcess;
|
|
201
|
+
/** Set false to render on the calling thread instead of a worker. */
|
|
202
|
+
worker?: boolean;
|
|
203
|
+
/** Project root for sampler voices; without it samplers render silent. */
|
|
204
|
+
projectRoot?: string;
|
|
205
|
+
/** Base delay before a dead player is respawned; doubles per attempt. */
|
|
206
|
+
respawnMs?: number;
|
|
207
|
+
/** Player lifecycle notices worth a status line. */
|
|
208
|
+
onStatus?: (status: AudioStatus) => void;
|
|
198
209
|
}>;
|
|
199
210
|
|
|
211
|
+
export type AudioStatus = Readonly<{
|
|
212
|
+
/** `restarting`: the player died and is being respawned; `stopped`: gave up. */
|
|
213
|
+
state: "restarting" | "stopped";
|
|
214
|
+
message: string;
|
|
215
|
+
}>;
|
|
216
|
+
|
|
217
|
+
/** Respawns of a dying player before playback is declared stopped. */
|
|
218
|
+
const MAX_RESPAWNS = 3;
|
|
219
|
+
/** A player that ran this long before dying resets the respawn budget. */
|
|
220
|
+
const RESPAWN_RESET_MS = 10_000;
|
|
221
|
+
/** Lead grows to this multiple of the last render time. */
|
|
222
|
+
const LEAD_RENDER_FACTOR = 1.5;
|
|
223
|
+
|
|
200
224
|
type Loop = Readonly<{
|
|
201
225
|
pcm: Int16Array;
|
|
202
226
|
frames: number;
|
|
203
227
|
framesPerBeat: number;
|
|
204
228
|
}>;
|
|
205
229
|
|
|
230
|
+
/**
|
|
231
|
+
* The click bus: the engine asks `beatAt` for the transport beat at a stream
|
|
232
|
+
* frame's wall time (undefined while the transport is stopped; negative
|
|
233
|
+
* during a count-in) and mixes a click on every step it crosses. It is a
|
|
234
|
+
* monitoring bus only, never part of a loop render or an export.
|
|
235
|
+
*/
|
|
236
|
+
export type ClickBus = Readonly<{
|
|
237
|
+
volume: number;
|
|
238
|
+
beatsPerBar: number;
|
|
239
|
+
subdivision: number;
|
|
240
|
+
beatAt: (monotonicMs: number) => number | undefined;
|
|
241
|
+
}>;
|
|
242
|
+
|
|
243
|
+
type LiveVoice = {
|
|
244
|
+
pcm: Int16Array;
|
|
245
|
+
frames: number;
|
|
246
|
+
/** Frames of this voice already mixed. */
|
|
247
|
+
position: number;
|
|
248
|
+
/** Fade-out frames left once released early; undefined while sounding. */
|
|
249
|
+
fadeLeft: number | undefined;
|
|
250
|
+
fadeFrames: number;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
type PlayRequest = {
|
|
254
|
+
score: TrackScore;
|
|
255
|
+
beat: number | undefined;
|
|
256
|
+
/** Monotonic ms when `beat` was observed. */
|
|
257
|
+
atMs: number;
|
|
258
|
+
generation: number;
|
|
259
|
+
waiters: { resolve: () => void; reject: (error: Error) => void }[];
|
|
260
|
+
};
|
|
261
|
+
|
|
206
262
|
/**
|
|
207
263
|
* The gapless audio engine. It renders the score into one seamless loop
|
|
208
264
|
* (tails folded onto the start) and streams it to a long-lived stdin player.
|
|
209
265
|
*
|
|
210
266
|
* Timing: the engine is paced by the same monotonic clock as the transport.
|
|
211
267
|
* Frame `n` of the stream belongs to wall time `start + n / rate`, and the
|
|
212
|
-
* engine keeps
|
|
268
|
+
* engine keeps a lead of audio queued ahead of now: at least `leadMs`, and
|
|
269
|
+
* 1.5x the last render time when renders are slower than that. The device
|
|
213
270
|
* plays the queue at its own rate, so what you hear trails the transport by
|
|
214
271
|
* the player's fixed output latency and never accumulates drift from
|
|
215
|
-
* re-renders.
|
|
216
|
-
* yet written, mapping the current beat
|
|
217
|
-
* length changes keep the musical position.
|
|
272
|
+
* re-renders. Renders run in a worker; an edit re-renders off-thread and
|
|
273
|
+
* swaps the buffer for every frame not yet written, mapping the current beat
|
|
274
|
+
* into the new loop, so tempo and length changes keep the musical position.
|
|
275
|
+
* Edits that arrive while a render is in flight coalesce into one render of
|
|
276
|
+
* the newest score.
|
|
218
277
|
*/
|
|
219
278
|
export class AudioEngine {
|
|
220
279
|
public readonly info: AudioBackendInfo;
|
|
221
280
|
private readonly lock: PlaybackLock;
|
|
222
|
-
|
|
223
|
-
private
|
|
281
|
+
public readonly sampleRate: number;
|
|
282
|
+
private leadFrames: number;
|
|
283
|
+
private readonly defaultLeadFrames: number;
|
|
224
284
|
private readonly tickMs: number;
|
|
225
285
|
private readonly now: () => number;
|
|
226
286
|
private readonly useTimer: boolean;
|
|
227
287
|
private readonly spawnPlayer: (command: readonly string[]) => PlayerProcess;
|
|
288
|
+
private readonly renderer: LoopRenderer;
|
|
289
|
+
private readonly respawnMs: number;
|
|
290
|
+
private readonly onStatus: ((status: AudioStatus) => void) | undefined;
|
|
228
291
|
private fallback: LoopPlayer | undefined;
|
|
229
292
|
private child: PlayerProcess | undefined;
|
|
230
293
|
private timer: ReturnType<typeof setInterval> | undefined;
|
|
@@ -238,6 +301,17 @@ export class AudioEngine {
|
|
|
238
301
|
private starting: Promise<void> | undefined;
|
|
239
302
|
private generation = 0;
|
|
240
303
|
private spawns = 0;
|
|
304
|
+
private queued: PlayRequest | undefined;
|
|
305
|
+
private draining = false;
|
|
306
|
+
private lastRenderMs = 0;
|
|
307
|
+
private respawns = 0;
|
|
308
|
+
private respawnTimer: ReturnType<typeof setTimeout> | undefined;
|
|
309
|
+
/** Play mode: keep a player running without a loop for live voices. */
|
|
310
|
+
private monitoring = false;
|
|
311
|
+
private readonly voices = new Map<number, LiveVoice>();
|
|
312
|
+
private click: ClickBus | undefined;
|
|
313
|
+
private clickVoices: { sound: Float32Array; position: number }[] = [];
|
|
314
|
+
private sounds: Readonly<Record<ClickLevel, Float32Array>> | undefined;
|
|
241
315
|
|
|
242
316
|
public constructor(options: AudioEngineOptions = {}) {
|
|
243
317
|
this.sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;
|
|
@@ -247,12 +321,22 @@ export class AudioEngine {
|
|
|
247
321
|
this.leadFrames = Math.round(
|
|
248
322
|
((options.leadMs ?? 200) * this.sampleRate) / 1000,
|
|
249
323
|
);
|
|
324
|
+
this.defaultLeadFrames = this.leadFrames;
|
|
250
325
|
this.tickMs = options.tickMs ?? 20;
|
|
251
326
|
this.now = options.now ?? (() => performance.now());
|
|
252
327
|
this.useTimer = options.timer ?? true;
|
|
253
328
|
this.spawnPlayer = options.spawn ?? spawnStdinPlayer;
|
|
329
|
+
this.respawnMs = options.respawnMs ?? 250;
|
|
330
|
+
this.onStatus = options.onStatus;
|
|
331
|
+
this.renderer = new LoopRenderer({
|
|
332
|
+
sampleRate: this.sampleRate,
|
|
333
|
+
...(options.worker === undefined ? {} : { worker: options.worker }),
|
|
334
|
+
...(options.projectRoot === undefined
|
|
335
|
+
? {}
|
|
336
|
+
: { projectRoot: options.projectRoot }),
|
|
337
|
+
});
|
|
254
338
|
if (this.info.backend === "afplay")
|
|
255
|
-
this.fallback = new LoopPlayer(options.lockPath);
|
|
339
|
+
this.fallback = new LoopPlayer(options.lockPath, options.projectRoot);
|
|
256
340
|
}
|
|
257
341
|
|
|
258
342
|
/** Whether a streaming player process is currently running. */
|
|
@@ -265,14 +349,180 @@ export class AudioEngine {
|
|
|
265
349
|
return this.spawns;
|
|
266
350
|
}
|
|
267
351
|
|
|
352
|
+
/** Milliseconds the most recent loop render took. */
|
|
353
|
+
public get renderMs(): number {
|
|
354
|
+
return this.lastRenderMs;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Set the queue lead (play mode drops it to ~60 ms so a key sounds soon
|
|
359
|
+
* after it is pressed); `undefined` restores the constructed lead.
|
|
360
|
+
*/
|
|
361
|
+
public setLeadMs(ms: number | undefined): void {
|
|
362
|
+
this.leadFrames =
|
|
363
|
+
ms === undefined
|
|
364
|
+
? this.defaultLeadFrames
|
|
365
|
+
: Math.max(1, Math.round((ms * this.sampleRate) / 1000));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Lead in milliseconds: how far ahead of now a new live voice sounds. */
|
|
369
|
+
public get leadMs(): number {
|
|
370
|
+
return (this.lead * 1000) / this.sampleRate;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Whether live voices and the click can sound (a streaming backend). */
|
|
374
|
+
public get canMonitor(): boolean {
|
|
375
|
+
return this.info.backend !== "none" && !this.fallback;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Play mode: keep the player running while the transport is stopped so
|
|
380
|
+
* live notes and the click sound over silence. Off stops a loopless player.
|
|
381
|
+
*/
|
|
382
|
+
public async monitor(on: boolean): Promise<void> {
|
|
383
|
+
if (!this.canMonitor) return;
|
|
384
|
+
this.monitoring = on;
|
|
385
|
+
if (on) {
|
|
386
|
+
if (this.child || this.starting) return;
|
|
387
|
+
this.starting = this.start(undefined, 0).finally(() => {
|
|
388
|
+
this.starting = undefined;
|
|
389
|
+
});
|
|
390
|
+
await this.starting;
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
this.voices.clear();
|
|
394
|
+
this.clickVoices = [];
|
|
395
|
+
if (!this.loop) await this.stopAsync();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
public get monitoringLive(): boolean {
|
|
399
|
+
return this.monitoring;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Start a live voice at the write head: it sounds `leadMs` from now, and
|
|
404
|
+
* the return value is the monotonic ms its first frame is scheduled at
|
|
405
|
+
* (before the device's own output latency). A
|
|
406
|
+
* voice with the same id is replaced in place (a re-rendered, longer or
|
|
407
|
+
* released note keeps its position, so the swap is seamless).
|
|
408
|
+
*/
|
|
409
|
+
public noteOn(id: number, note: LiveNotePcm): number {
|
|
410
|
+
const existing = this.voices.get(id);
|
|
411
|
+
if (existing) {
|
|
412
|
+
existing.pcm = note.pcm;
|
|
413
|
+
existing.frames = note.frames;
|
|
414
|
+
existing.fadeLeft = undefined;
|
|
415
|
+
} else
|
|
416
|
+
this.voices.set(id, {
|
|
417
|
+
pcm: note.pcm,
|
|
418
|
+
frames: note.frames,
|
|
419
|
+
position: 0,
|
|
420
|
+
fadeLeft: undefined,
|
|
421
|
+
fadeFrames: Math.max(1, Math.round(this.sampleRate * 0.01)),
|
|
422
|
+
});
|
|
423
|
+
// Write what is due now so the voice joins the very next chunk.
|
|
424
|
+
const first = this.written;
|
|
425
|
+
this.pump();
|
|
426
|
+
return this.frameMs(first);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Fade a live voice out over ~10 ms (sustain lifted, mode left). */
|
|
430
|
+
public noteOff(id: number): void {
|
|
431
|
+
const voice = this.voices.get(id);
|
|
432
|
+
if (voice && voice.fadeLeft === undefined)
|
|
433
|
+
voice.fadeLeft = voice.fadeFrames;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Frames of live voice `id` already mixed, or undefined once finished. */
|
|
437
|
+
public voicePosition(id: number): number | undefined {
|
|
438
|
+
return this.voices.get(id)?.position;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
public get liveVoices(): number {
|
|
442
|
+
return this.voices.size;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Mix the click bus into the stream (undefined turns it off). */
|
|
446
|
+
public setClick(click: ClickBus | undefined): void {
|
|
447
|
+
this.click = click;
|
|
448
|
+
if (!click) this.clickVoices = [];
|
|
449
|
+
else this.sounds ??= clickSounds(this.sampleRate);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Frames kept queued ahead of the clock right now. */
|
|
453
|
+
public get lead(): number {
|
|
454
|
+
// Play mode keeps its short lead: renders run off-thread, so a slow
|
|
455
|
+
// render delays the swap, not the stream.
|
|
456
|
+
const adaptive =
|
|
457
|
+
this.monitoring && this.renderer.offThread
|
|
458
|
+
? 0
|
|
459
|
+
: Math.round(
|
|
460
|
+
(LEAD_RENDER_FACTOR * this.lastRenderMs * this.sampleRate) / 1000,
|
|
461
|
+
);
|
|
462
|
+
// The pump never queues more than a second, so the lead stays under it.
|
|
463
|
+
return Math.min(
|
|
464
|
+
Math.max(this.leadFrames, adaptive),
|
|
465
|
+
Math.floor(this.sampleRate * 0.9),
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
268
469
|
/**
|
|
269
470
|
* Start playback at `beat`, or, when already playing, swap in the new
|
|
270
|
-
* score at the current position without restarting the player.
|
|
471
|
+
* score at the current position without restarting the player. Resolves
|
|
472
|
+
* once the render has been applied (or superseded by a newer one).
|
|
271
473
|
*/
|
|
272
|
-
public
|
|
273
|
-
if (this.info.backend === "none") return;
|
|
474
|
+
public play(score: TrackScore, beat?: number): Promise<void> {
|
|
475
|
+
if (this.info.backend === "none") return Promise.resolve();
|
|
274
476
|
if (this.fallback) return this.fallback.play(score, beat);
|
|
275
|
-
|
|
477
|
+
return new Promise<void>((resolve, reject) => {
|
|
478
|
+
const waiters = this.queued?.waiters ?? [];
|
|
479
|
+
waiters.push({ resolve, reject });
|
|
480
|
+
this.queued = {
|
|
481
|
+
score,
|
|
482
|
+
beat,
|
|
483
|
+
atMs: this.now(),
|
|
484
|
+
generation: this.generation,
|
|
485
|
+
waiters,
|
|
486
|
+
};
|
|
487
|
+
void this.drain();
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private async drain(): Promise<void> {
|
|
492
|
+
if (this.draining) return;
|
|
493
|
+
this.draining = true;
|
|
494
|
+
try {
|
|
495
|
+
while (this.queued) {
|
|
496
|
+
const request = this.queued;
|
|
497
|
+
this.queued = undefined;
|
|
498
|
+
try {
|
|
499
|
+
const render = await this.renderer.render(request.score);
|
|
500
|
+
this.lastRenderMs = render.renderMs;
|
|
501
|
+
// Stopped while rendering: the result is stale, not an error.
|
|
502
|
+
if (request.generation === this.generation)
|
|
503
|
+
await this.apply(this.toLoop(render, request.score), request);
|
|
504
|
+
for (const waiter of request.waiters) waiter.resolve();
|
|
505
|
+
} catch (error) {
|
|
506
|
+
for (const waiter of request.waiters)
|
|
507
|
+
waiter.reject(
|
|
508
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
} finally {
|
|
513
|
+
this.draining = false;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
private async apply(loop: Loop, request: PlayRequest): Promise<void> {
|
|
518
|
+
// The transport kept moving while the render ran.
|
|
519
|
+
const beat =
|
|
520
|
+
request.beat === undefined
|
|
521
|
+
? undefined
|
|
522
|
+
: request.beat +
|
|
523
|
+
((this.now() - request.atMs) * this.sampleRate) /
|
|
524
|
+
1000 /
|
|
525
|
+
loop.framesPerBeat;
|
|
276
526
|
if (this.child) {
|
|
277
527
|
this.swap(loop, beat);
|
|
278
528
|
return;
|
|
@@ -303,10 +553,14 @@ export class AudioEngine {
|
|
|
303
553
|
* by adding it.
|
|
304
554
|
*/
|
|
305
555
|
private queuedBeats(loop: Loop): number {
|
|
556
|
+
return this.queuedFrames() / loop.framesPerBeat;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private queuedFrames(): number {
|
|
306
560
|
const elapsed = Math.floor(
|
|
307
561
|
((this.now() - this.startMs) * this.sampleRate) / 1000,
|
|
308
562
|
);
|
|
309
|
-
return Math.max(0, this.written - elapsed)
|
|
563
|
+
return Math.max(0, this.written - elapsed);
|
|
310
564
|
}
|
|
311
565
|
|
|
312
566
|
public stop(): void {
|
|
@@ -315,11 +569,19 @@ export class AudioEngine {
|
|
|
315
569
|
|
|
316
570
|
public async stopAsync(): Promise<void> {
|
|
317
571
|
this.generation += 1;
|
|
572
|
+
this.respawns = 0;
|
|
573
|
+
if (this.respawnTimer) clearTimeout(this.respawnTimer);
|
|
574
|
+
this.respawnTimer = undefined;
|
|
318
575
|
if (this.fallback) {
|
|
319
576
|
this.fallback.stop();
|
|
320
577
|
return;
|
|
321
578
|
}
|
|
322
579
|
await this.starting?.catch(() => undefined);
|
|
580
|
+
if (this.monitoring && this.child) {
|
|
581
|
+
// Play mode: the transport stopped, the live player keeps running.
|
|
582
|
+
this.loop = undefined;
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
323
585
|
if (this.timer) clearInterval(this.timer);
|
|
324
586
|
this.timer = undefined;
|
|
325
587
|
const child = this.child;
|
|
@@ -337,6 +599,13 @@ export class AudioEngine {
|
|
|
337
599
|
await this.lock.release();
|
|
338
600
|
}
|
|
339
601
|
|
|
602
|
+
/** Stops playback and releases the render worker. */
|
|
603
|
+
public async dispose(): Promise<void> {
|
|
604
|
+
this.monitoring = false;
|
|
605
|
+
await this.stopAsync();
|
|
606
|
+
this.renderer.dispose();
|
|
607
|
+
}
|
|
608
|
+
|
|
340
609
|
/**
|
|
341
610
|
* Write every frame due by now plus the lead. Called by the timer; tests
|
|
342
611
|
* call it directly with an injected clock.
|
|
@@ -344,22 +613,22 @@ export class AudioEngine {
|
|
|
344
613
|
public pump(): void {
|
|
345
614
|
const child = this.child;
|
|
346
615
|
const loop = this.loop;
|
|
347
|
-
if (!child || !loop) return;
|
|
616
|
+
if (!child || (!loop && !this.monitoring)) return;
|
|
348
617
|
const due =
|
|
349
618
|
Math.floor(((this.now() - this.startMs) * this.sampleRate) / 1000) +
|
|
350
|
-
this.
|
|
619
|
+
this.lead;
|
|
351
620
|
let remaining = due - this.written;
|
|
352
621
|
if (remaining <= 0) return;
|
|
353
622
|
// Never queue more than one second at once, e.g. after a stalled loop.
|
|
354
623
|
if (remaining > this.sampleRate) {
|
|
355
624
|
const skipped = remaining - this.sampleRate;
|
|
356
|
-
this.cursor = (this.cursor + skipped) % loop.frames;
|
|
625
|
+
if (loop) this.cursor = (this.cursor + skipped) % loop.frames;
|
|
357
626
|
this.written += skipped;
|
|
358
627
|
remaining = this.sampleRate;
|
|
359
628
|
}
|
|
360
629
|
const out = new Int16Array(remaining * RENDER_CHANNELS);
|
|
361
630
|
let offset = 0;
|
|
362
|
-
while (offset < remaining) {
|
|
631
|
+
while (loop && offset < remaining) {
|
|
363
632
|
const take = Math.min(remaining - offset, loop.frames - this.cursor);
|
|
364
633
|
out.set(
|
|
365
634
|
loop.pcm.subarray(
|
|
@@ -371,6 +640,7 @@ export class AudioEngine {
|
|
|
371
640
|
offset += take;
|
|
372
641
|
this.cursor = (this.cursor + take) % loop.frames;
|
|
373
642
|
}
|
|
643
|
+
this.mixLive(out, this.written, remaining);
|
|
374
644
|
this.written += remaining;
|
|
375
645
|
try {
|
|
376
646
|
child.stdin.write(new Uint8Array(out.buffer));
|
|
@@ -380,14 +650,72 @@ export class AudioEngine {
|
|
|
380
650
|
}
|
|
381
651
|
}
|
|
382
652
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
653
|
+
/** Wall time (monotonic ms) stream frame `frame` sounds at. */
|
|
654
|
+
private frameMs(frame: number): number {
|
|
655
|
+
return this.startMs + (frame * 1000) / this.sampleRate;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** Add live voices and the click bus to a block about to be written. */
|
|
659
|
+
private mixLive(out: Int16Array, firstFrame: number, frames: number): void {
|
|
660
|
+
if (this.voices.size === 0 && !this.click && this.clickVoices.length === 0)
|
|
661
|
+
return;
|
|
662
|
+
for (const [id, voice] of this.voices) {
|
|
663
|
+
let index = 0;
|
|
664
|
+
for (; index < frames && voice.position < voice.frames; index += 1) {
|
|
665
|
+
let gain = 1;
|
|
666
|
+
if (voice.fadeLeft !== undefined) {
|
|
667
|
+
if (voice.fadeLeft <= 0) break;
|
|
668
|
+
gain = voice.fadeLeft / voice.fadeFrames;
|
|
669
|
+
voice.fadeLeft -= 1;
|
|
670
|
+
}
|
|
671
|
+
const at = index * RENDER_CHANNELS;
|
|
672
|
+
const from = voice.position * RENDER_CHANNELS;
|
|
673
|
+
out[at] = clamp16(out[at]! + voice.pcm[from]! * gain);
|
|
674
|
+
out[at + 1] = clamp16(out[at + 1]! + voice.pcm[from + 1]! * gain);
|
|
675
|
+
voice.position += 1;
|
|
676
|
+
}
|
|
677
|
+
if (
|
|
678
|
+
voice.position >= voice.frames ||
|
|
679
|
+
(voice.fadeLeft !== undefined && voice.fadeLeft <= 0)
|
|
680
|
+
)
|
|
681
|
+
this.voices.delete(id);
|
|
682
|
+
}
|
|
683
|
+
const click = this.click;
|
|
684
|
+
if (click && this.sounds) {
|
|
685
|
+
const from = click.beatAt(this.frameMs(firstFrame));
|
|
686
|
+
const to = click.beatAt(this.frameMs(firstFrame + frames));
|
|
687
|
+
// A stopped transport, a seek or a loop wrap fires no click.
|
|
688
|
+
if (from !== undefined && to !== undefined && to > from && to - from < 2)
|
|
689
|
+
for (const event of clicksIn(from, to, frames, click)) {
|
|
690
|
+
this.clickVoices.push({
|
|
691
|
+
sound: this.sounds[event.level],
|
|
692
|
+
position: -event.offset,
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
if (this.clickVoices.length === 0) return;
|
|
697
|
+
const level = 32767 * Math.max(0, Math.min(1, click?.volume ?? 0)) * 0.5;
|
|
698
|
+
for (const voice of this.clickVoices) {
|
|
699
|
+
for (let index = 0; index < frames; index += 1) {
|
|
700
|
+
const position = voice.position + index;
|
|
701
|
+
if (position < 0) continue;
|
|
702
|
+
if (position >= voice.sound.length) break;
|
|
703
|
+
const value = voice.sound[position]! * level;
|
|
704
|
+
const at = index * RENDER_CHANNELS;
|
|
705
|
+
out[at] = clamp16(out[at]! + value);
|
|
706
|
+
out[at + 1] = clamp16(out[at + 1]! + value);
|
|
707
|
+
}
|
|
708
|
+
voice.position += frames;
|
|
709
|
+
}
|
|
710
|
+
this.clickVoices = this.clickVoices.filter(
|
|
711
|
+
(voice) => voice.position < voice.sound.length,
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
private toLoop(render: LoopRender, score: TrackScore): Loop {
|
|
388
716
|
return {
|
|
389
|
-
pcm:
|
|
390
|
-
frames:
|
|
717
|
+
pcm: render.pcm,
|
|
718
|
+
frames: render.frames,
|
|
391
719
|
framesPerBeat: (60 * this.sampleRate) / score.tempoBpm,
|
|
392
720
|
};
|
|
393
721
|
}
|
|
@@ -416,7 +744,7 @@ export class AudioEngine {
|
|
|
416
744
|
this.loop = next;
|
|
417
745
|
}
|
|
418
746
|
|
|
419
|
-
private async start(loop: Loop, beat: number): Promise<void> {
|
|
747
|
+
private async start(loop: Loop | undefined, beat: number): Promise<void> {
|
|
420
748
|
const generation = this.generation;
|
|
421
749
|
if (!(await this.lock.acquire())) return;
|
|
422
750
|
if (generation !== this.generation) {
|
|
@@ -435,20 +763,62 @@ export class AudioEngine {
|
|
|
435
763
|
this.loop = loop;
|
|
436
764
|
this.startMs = this.now();
|
|
437
765
|
this.written = 0;
|
|
438
|
-
this.cursor = this.frameForBeat(loop, beat);
|
|
766
|
+
this.cursor = loop ? this.frameForBeat(loop, beat) : 0;
|
|
439
767
|
void child.exited.then(() => {
|
|
440
768
|
if (this.child !== child) return;
|
|
441
|
-
|
|
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();
|
|
769
|
+
this.onPlayerExit(generation);
|
|
448
770
|
});
|
|
449
771
|
this.pump();
|
|
450
772
|
if (this.useTimer) this.timer = setInterval(() => this.pump(), this.tickMs);
|
|
451
773
|
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* The player died while we still want sound. Respawn it a bounded number
|
|
777
|
+
* of times, resuming from the frame the listener last heard, and report
|
|
778
|
+
* each attempt; after that, stop cleanly rather than show a silent
|
|
779
|
+
* "playing" transport.
|
|
780
|
+
*/
|
|
781
|
+
private onPlayerExit(generation: number): void {
|
|
782
|
+
const loop = this.loop;
|
|
783
|
+
const ranMs = this.now() - this.startMs;
|
|
784
|
+
// Frame the device was playing when the stream ended.
|
|
785
|
+
const heard = loop
|
|
786
|
+
? (((this.cursor - this.queuedFrames()) % loop.frames) + loop.frames) %
|
|
787
|
+
loop.frames
|
|
788
|
+
: 0;
|
|
789
|
+
const diedAt = this.now();
|
|
790
|
+
this.child = undefined;
|
|
791
|
+
this.loop = undefined;
|
|
792
|
+
if (this.timer) clearInterval(this.timer);
|
|
793
|
+
this.timer = undefined;
|
|
794
|
+
void this.lock.release().then(() => {
|
|
795
|
+
if ((!loop && !this.monitoring) || generation !== this.generation) return;
|
|
796
|
+
if (ranMs >= RESPAWN_RESET_MS) this.respawns = 0;
|
|
797
|
+
if (this.respawns >= MAX_RESPAWNS) {
|
|
798
|
+
this.onStatus?.({
|
|
799
|
+
state: "stopped",
|
|
800
|
+
message: `audio player exited ${MAX_RESPAWNS} times; playback stopped`,
|
|
801
|
+
});
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
this.respawns += 1;
|
|
805
|
+
const delay = this.respawnMs * 2 ** (this.respawns - 1);
|
|
806
|
+
this.onStatus?.({
|
|
807
|
+
state: "restarting",
|
|
808
|
+
message: `audio player exited; restarting (${this.respawns}/${MAX_RESPAWNS})`,
|
|
809
|
+
});
|
|
810
|
+
this.respawnTimer = setTimeout(() => {
|
|
811
|
+
this.respawnTimer = undefined;
|
|
812
|
+
if (generation !== this.generation || this.child || this.starting)
|
|
813
|
+
return;
|
|
814
|
+
const frame = heard + ((this.now() - diedAt) * this.sampleRate) / 1000;
|
|
815
|
+
const beat = loop ? frame / loop.framesPerBeat : 0;
|
|
816
|
+
this.starting = this.start(loop, beat).finally(() => {
|
|
817
|
+
this.starting = undefined;
|
|
818
|
+
});
|
|
819
|
+
}, delay);
|
|
820
|
+
});
|
|
821
|
+
}
|
|
452
822
|
}
|
|
453
823
|
|
|
454
824
|
function spawnStdinPlayer(command: readonly string[]): PlayerProcess {
|
|
@@ -465,3 +835,7 @@ function spawnStdinPlayer(command: readonly string[]): PlayerProcess {
|
|
|
465
835
|
kill: () => child.kill(),
|
|
466
836
|
};
|
|
467
837
|
}
|
|
838
|
+
|
|
839
|
+
function clamp16(value: number): number {
|
|
840
|
+
return Math.max(-32768, Math.min(32767, Math.round(value)));
|
|
841
|
+
}
|