@hraness/dawg 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/DAWG.md +196 -11
  3. package/README.md +79 -30
  4. package/core/diff.ts +249 -0
  5. package/core/key.ts +43 -0
  6. package/core/pitch.ts +60 -0
  7. package/core/score.ts +324 -1
  8. package/core/sdk/eval-child.ts +113 -0
  9. package/core/sdk/eval.ts +257 -0
  10. package/core/sdk/print.ts +393 -0
  11. package/core/sdk/v1.ts +954 -0
  12. package/core/slug.ts +19 -0
  13. package/package.json +8 -5
  14. package/src/agent/agent.ts +280 -14
  15. package/src/agent/brief.ts +23 -3
  16. package/src/agent/gateway.ts +215 -41
  17. package/src/agent/models.ts +633 -0
  18. package/src/agent/ops.ts +3 -18
  19. package/src/agent/planner.ts +38 -0
  20. package/src/agent/provider.ts +234 -106
  21. package/src/agent/sse.ts +31 -7
  22. package/src/agent/tools.ts +398 -1
  23. package/src/agent/usage.ts +296 -0
  24. package/src/agent/workspace.ts +683 -0
  25. package/src/agent/xcb-agent.ts +27 -12
  26. package/src/agent/xcb.ts +233 -21
  27. package/src/audio/click.ts +125 -0
  28. package/src/audio/engine.ts +407 -33
  29. package/src/audio/live.ts +152 -0
  30. package/src/audio/player.ts +19 -4
  31. package/src/audio/render-worker.ts +68 -0
  32. package/src/audio/renderer.ts +174 -0
  33. package/src/audio/sampler.ts +292 -0
  34. package/src/audio/samples.ts +683 -0
  35. package/src/audio/wav.ts +290 -76
  36. package/src/auth/cli.ts +146 -27
  37. package/src/auth/credentials.ts +167 -41
  38. package/src/auth/discover.ts +481 -0
  39. package/src/auth/login.ts +885 -128
  40. package/src/auth/openrouter.ts +206 -0
  41. package/src/auth/picker.ts +282 -0
  42. package/src/auth/runner.ts +25 -2
  43. package/src/auth/tui.ts +60 -43
  44. package/src/commands/edit.ts +170 -0
  45. package/src/commands/help.ts +247 -0
  46. package/src/commands/history.ts +32 -19
  47. package/src/commands/music.ts +24 -7
  48. package/src/commands/sample.ts +302 -0
  49. package/src/main.ts +1043 -166
  50. package/src/media/analyze.ts +364 -0
  51. package/src/media/backend.ts +253 -0
  52. package/src/media/cli.ts +173 -0
  53. package/src/media/download.ts +281 -0
  54. package/src/media/dsp.ts +281 -0
  55. package/src/media/import.ts +130 -0
  56. package/src/media/lyrics.ts +201 -0
  57. package/src/media/notes.ts +363 -0
  58. package/src/media/paths.ts +168 -0
  59. package/src/media/process.ts +226 -0
  60. package/src/media/registry.ts +9 -0
  61. package/src/media/sidecar.ts +72 -0
  62. package/src/media/stemdeck.ts +254 -0
  63. package/src/media/stems.ts +173 -0
  64. package/src/media/tools.ts +292 -0
  65. package/src/media/types.ts +92 -0
  66. package/src/media/vendor/basic-pitch.ts +261 -0
  67. package/src/media/vendor/drums.ts +817 -0
  68. package/src/media/vendor/grid.ts +203 -0
  69. package/src/media/vendor/util.ts +139 -0
  70. package/src/media/vendor/wav.ts +233 -0
  71. package/src/project/check.ts +80 -0
  72. package/src/project/init.ts +253 -0
  73. package/src/project/sync.ts +432 -0
  74. package/src/project/typecheck.ts +149 -0
  75. package/src/render.ts +28 -6
  76. package/src/session/attach.ts +3 -4
  77. package/src/session/daemon.ts +25 -8
  78. package/src/session/delta.ts +249 -0
  79. package/src/session/naming.ts +3 -37
  80. package/src/session/port.ts +26 -6
  81. package/src/session/rebase.ts +38 -8
  82. package/src/session/store.ts +116 -21
  83. package/src/tui/menu.ts +1083 -0
  84. package/src/tui/play-mode.ts +442 -0
  85. package/src/tui/play-session.ts +636 -0
  86. package/src/web/fetch.ts +340 -0
  87. package/src/web/http.ts +137 -0
  88. package/src/web/search.ts +681 -0
  89. package/tui/activity.ts +42 -3
  90. package/tui/app.ts +261 -18
  91. package/tui/drums.ts +44 -0
  92. package/tui/highway.ts +18 -2
  93. package/tui/layers.ts +14 -2
  94. package/tui/play-strip.ts +143 -0
@@ -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 { DEFAULT_SAMPLE_RATE, RENDER_CHANNELS, renderScorePcm } from "./wav.ts";
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 exactly `leadMs` of audio queued ahead of now. The device
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. 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.
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
- private readonly sampleRate: number;
223
- private readonly leadFrames: number;
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 async play(score: TrackScore, beat?: number): Promise<void> {
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
- const loop = this.render(score);
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) / loop.framesPerBeat;
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.leadFrames;
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
- private render(score: TrackScore): Loop {
384
- const audio = renderScorePcm(score, {
385
- sampleRate: this.sampleRate,
386
- loop: true,
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: audio.pcm,
390
- frames: audio.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
- // 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();
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
+ }