@hraness/dawg 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
@@ -1,7 +1,11 @@
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 { levelOf, type SoundLevel } from "./preview.ts";
8
+ import { DEFAULT_SAMPLE_RATE, RENDER_CHANNELS } from "./wav.ts";
5
9
 
6
10
  /**
7
11
  * How dawgd (or a file-mode window) makes sound.
@@ -195,36 +199,96 @@ export type AudioEngineOptions = Readonly<{
195
199
  /** Set false to drive `pump()` manually (tests). */
196
200
  timer?: boolean;
197
201
  spawn?: (command: readonly string[]) => PlayerProcess;
202
+ /** Set false to render on the calling thread instead of a worker. */
203
+ worker?: boolean;
204
+ /** Project root for sampler voices; without it samplers render silent. */
205
+ projectRoot?: string;
206
+ /** Base delay before a dead player is respawned; doubles per attempt. */
207
+ respawnMs?: number;
208
+ /** Player lifecycle notices worth a status line. */
209
+ onStatus?: (status: AudioStatus) => void;
198
210
  }>;
199
211
 
212
+ export type AudioStatus = Readonly<{
213
+ /** `restarting`: the player died and is being respawned; `stopped`: gave up. */
214
+ state: "restarting" | "stopped";
215
+ message: string;
216
+ }>;
217
+
218
+ /** Respawns of a dying player before playback is declared stopped. */
219
+ const MAX_RESPAWNS = 3;
220
+ /** A player that ran this long before dying resets the respawn budget. */
221
+ const RESPAWN_RESET_MS = 10_000;
222
+ /** Lead grows to this multiple of the last render time. */
223
+ const LEAD_RENDER_FACTOR = 1.5;
224
+
200
225
  type Loop = Readonly<{
201
226
  pcm: Int16Array;
202
227
  frames: number;
203
228
  framesPerBeat: number;
204
229
  }>;
205
230
 
231
+ /**
232
+ * The click bus: the engine asks `beatAt` for the transport beat at a stream
233
+ * frame's wall time (undefined while the transport is stopped; negative
234
+ * during a count-in) and mixes a click on every step it crosses. It is a
235
+ * monitoring bus only, never part of a loop render or an export.
236
+ */
237
+ export type ClickBus = Readonly<{
238
+ volume: number;
239
+ beatsPerBar: number;
240
+ subdivision: number;
241
+ beatAt: (monotonicMs: number) => number | undefined;
242
+ }>;
243
+
244
+ type LiveVoice = {
245
+ pcm: Int16Array;
246
+ frames: number;
247
+ /** Frames of this voice already mixed. */
248
+ position: number;
249
+ /** Fade-out frames left once released early; undefined while sounding. */
250
+ fadeLeft: number | undefined;
251
+ fadeFrames: number;
252
+ };
253
+
254
+ type PlayRequest = {
255
+ score: TrackScore;
256
+ beat: number | undefined;
257
+ /** Monotonic ms when `beat` was observed. */
258
+ atMs: number;
259
+ generation: number;
260
+ waiters: { resolve: () => void; reject: (error: Error) => void }[];
261
+ };
262
+
206
263
  /**
207
264
  * The gapless audio engine. It renders the score into one seamless loop
208
265
  * (tails folded onto the start) and streams it to a long-lived stdin player.
209
266
  *
210
267
  * Timing: the engine is paced by the same monotonic clock as the transport.
211
268
  * 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
269
+ * engine keeps a lead of audio queued ahead of now: at least `leadMs`, and
270
+ * 1.5x the last render time when renders are slower than that. The device
213
271
  * plays the queue at its own rate, so what you hear trails the transport by
214
272
  * 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.
273
+ * re-renders. Renders run in a worker; an edit re-renders off-thread and
274
+ * swaps the buffer for every frame not yet written, mapping the current beat
275
+ * into the new loop, so tempo and length changes keep the musical position.
276
+ * Edits that arrive while a render is in flight coalesce into one render of
277
+ * the newest score.
218
278
  */
219
279
  export class AudioEngine {
220
280
  public readonly info: AudioBackendInfo;
221
281
  private readonly lock: PlaybackLock;
222
- private readonly sampleRate: number;
223
- private readonly leadFrames: number;
282
+ public readonly sampleRate: number;
283
+ private leadFrames: number;
284
+ private readonly defaultLeadFrames: number;
224
285
  private readonly tickMs: number;
225
286
  private readonly now: () => number;
226
287
  private readonly useTimer: boolean;
227
288
  private readonly spawnPlayer: (command: readonly string[]) => PlayerProcess;
289
+ private readonly renderer: LoopRenderer;
290
+ private readonly respawnMs: number;
291
+ private readonly onStatus: ((status: AudioStatus) => void) | undefined;
228
292
  private fallback: LoopPlayer | undefined;
229
293
  private child: PlayerProcess | undefined;
230
294
  private timer: ReturnType<typeof setInterval> | undefined;
@@ -238,6 +302,22 @@ export class AudioEngine {
238
302
  private starting: Promise<void> | undefined;
239
303
  private generation = 0;
240
304
  private spawns = 0;
305
+ private queued: PlayRequest | undefined;
306
+ private draining = false;
307
+ private lastRenderMs = 0;
308
+ /** The loop last swapped in, for `level` (measured on demand, once). */
309
+ private lastLoopPcm: Int16Array | undefined;
310
+ private measured: { pcm: Int16Array; level: SoundLevel } | undefined;
311
+ private respawns = 0;
312
+ private respawnTimer: ReturnType<typeof setTimeout> | undefined;
313
+ /** Play mode: keep a player running without a loop for live voices. */
314
+ private monitoring = false;
315
+ /** A lead set by `setLeadMs` (play mode, auditions) is not adapted. */
316
+ private leadPinned = false;
317
+ private readonly voices = new Map<number, LiveVoice>();
318
+ private click: ClickBus | undefined;
319
+ private clickVoices: { sound: Float32Array; position: number }[] = [];
320
+ private sounds: Readonly<Record<ClickLevel, Float32Array>> | undefined;
241
321
 
242
322
  public constructor(options: AudioEngineOptions = {}) {
243
323
  this.sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;
@@ -247,12 +327,22 @@ export class AudioEngine {
247
327
  this.leadFrames = Math.round(
248
328
  ((options.leadMs ?? 200) * this.sampleRate) / 1000,
249
329
  );
330
+ this.defaultLeadFrames = this.leadFrames;
250
331
  this.tickMs = options.tickMs ?? 20;
251
332
  this.now = options.now ?? (() => performance.now());
252
333
  this.useTimer = options.timer ?? true;
253
334
  this.spawnPlayer = options.spawn ?? spawnStdinPlayer;
335
+ this.respawnMs = options.respawnMs ?? 250;
336
+ this.onStatus = options.onStatus;
337
+ this.renderer = new LoopRenderer({
338
+ sampleRate: this.sampleRate,
339
+ ...(options.worker === undefined ? {} : { worker: options.worker }),
340
+ ...(options.projectRoot === undefined
341
+ ? {}
342
+ : { projectRoot: options.projectRoot }),
343
+ });
254
344
  if (this.info.backend === "afplay")
255
- this.fallback = new LoopPlayer(options.lockPath);
345
+ this.fallback = new LoopPlayer(options.lockPath, options.projectRoot);
256
346
  }
257
347
 
258
348
  /** Whether a streaming player process is currently running. */
@@ -265,14 +355,196 @@ export class AudioEngine {
265
355
  return this.spawns;
266
356
  }
267
357
 
358
+ /** Milliseconds the most recent loop render took. */
359
+ public get renderMs(): number {
360
+ return this.lastRenderMs;
361
+ }
362
+
363
+ /**
364
+ * Set the queue lead (play mode drops it to ~60 ms so a key sounds soon
365
+ * after it is pressed); `undefined` restores the constructed lead.
366
+ */
367
+ public setLeadMs(ms: number | undefined): void {
368
+ this.leadPinned = ms !== undefined;
369
+ this.leadFrames =
370
+ ms === undefined
371
+ ? this.defaultLeadFrames
372
+ : Math.max(1, Math.round((ms * this.sampleRate) / 1000));
373
+ }
374
+
375
+ /** Lead in milliseconds: how far ahead of now a new live voice sounds. */
376
+ public get leadMs(): number {
377
+ return (this.lead * 1000) / this.sampleRate;
378
+ }
379
+
380
+ /** Whether live voices and the click can sound (a streaming backend). */
381
+ /**
382
+ * RMS, peak and clips of the loop now playing (the audition meter);
383
+ * measured once per render, on first read. Undefined before any render
384
+ * or with no audio device.
385
+ */
386
+ public get level(): SoundLevel | undefined {
387
+ const pcm = this.lastLoopPcm;
388
+ if (!pcm) return undefined;
389
+ if (this.measured?.pcm !== pcm)
390
+ this.measured = { pcm, level: levelOf(pcm) };
391
+ return this.measured.level;
392
+ }
393
+
394
+ public get canMonitor(): boolean {
395
+ return this.info.backend !== "none" && !this.fallback;
396
+ }
397
+
398
+ /**
399
+ * Play mode: keep the player running while the transport is stopped so
400
+ * live notes and the click sound over silence. Off stops a loopless player.
401
+ */
402
+ public async monitor(on: boolean): Promise<void> {
403
+ if (!this.canMonitor) return;
404
+ this.monitoring = on;
405
+ if (on) {
406
+ if (this.child || this.starting) return;
407
+ this.starting = this.start(undefined, 0).finally(() => {
408
+ this.starting = undefined;
409
+ });
410
+ await this.starting;
411
+ return;
412
+ }
413
+ this.voices.clear();
414
+ this.clickVoices = [];
415
+ if (!this.loop) await this.stopAsync();
416
+ }
417
+
418
+ public get monitoringLive(): boolean {
419
+ return this.monitoring;
420
+ }
421
+
422
+ /**
423
+ * Start a live voice at the write head: it sounds `leadMs` from now, and
424
+ * the return value is the monotonic ms its first frame is scheduled at
425
+ * (before the device's own output latency). A
426
+ * voice with the same id is replaced in place (a re-rendered, longer or
427
+ * released note keeps its position, so the swap is seamless).
428
+ */
429
+ public noteOn(id: number, note: LiveNotePcm): number {
430
+ const existing = this.voices.get(id);
431
+ if (existing) {
432
+ existing.pcm = note.pcm;
433
+ existing.frames = note.frames;
434
+ existing.fadeLeft = undefined;
435
+ } else
436
+ this.voices.set(id, {
437
+ pcm: note.pcm,
438
+ frames: note.frames,
439
+ position: 0,
440
+ fadeLeft: undefined,
441
+ fadeFrames: Math.max(1, Math.round(this.sampleRate * 0.01)),
442
+ });
443
+ // Write what is due now so the voice joins the very next chunk.
444
+ const first = this.written;
445
+ this.pump();
446
+ return this.frameMs(first);
447
+ }
448
+
449
+ /** Fade a live voice out over ~10 ms (sustain lifted, mode left). */
450
+ public noteOff(id: number): void {
451
+ const voice = this.voices.get(id);
452
+ if (voice && voice.fadeLeft === undefined)
453
+ voice.fadeLeft = voice.fadeFrames;
454
+ }
455
+
456
+ /** Frames of live voice `id` already mixed, or undefined once finished. */
457
+ public voicePosition(id: number): number | undefined {
458
+ return this.voices.get(id)?.position;
459
+ }
460
+
461
+ public get liveVoices(): number {
462
+ return this.voices.size;
463
+ }
464
+
465
+ /** Mix the click bus into the stream (undefined turns it off). */
466
+ public setClick(click: ClickBus | undefined): void {
467
+ this.click = click;
468
+ if (!click) this.clickVoices = [];
469
+ else this.sounds ??= clickSounds(this.sampleRate);
470
+ }
471
+
472
+ /** Frames kept queued ahead of the clock right now. */
473
+ public get lead(): number {
474
+ // Play mode and auditions keep their short lead: renders run
475
+ // off-thread, so a slow render delays the swap, not the stream.
476
+ const adaptive =
477
+ (this.monitoring || this.leadPinned) && this.renderer.offThread
478
+ ? 0
479
+ : Math.round(
480
+ (LEAD_RENDER_FACTOR * this.lastRenderMs * this.sampleRate) / 1000,
481
+ );
482
+ // The pump never queues more than a second, so the lead stays under it.
483
+ return Math.min(
484
+ Math.max(this.leadFrames, adaptive),
485
+ Math.floor(this.sampleRate * 0.9),
486
+ );
487
+ }
488
+
268
489
  /**
269
490
  * Start playback at `beat`, or, when already playing, swap in the new
270
- * score at the current position without restarting the player.
491
+ * score at the current position without restarting the player. Resolves
492
+ * once the render has been applied (or superseded by a newer one).
271
493
  */
272
- public async play(score: TrackScore, beat?: number): Promise<void> {
273
- if (this.info.backend === "none") return;
494
+ public play(score: TrackScore, beat?: number): Promise<void> {
495
+ if (this.info.backend === "none") return Promise.resolve();
274
496
  if (this.fallback) return this.fallback.play(score, beat);
275
- const loop = this.render(score);
497
+ return new Promise<void>((resolve, reject) => {
498
+ const waiters = this.queued?.waiters ?? [];
499
+ waiters.push({ resolve, reject });
500
+ this.queued = {
501
+ score,
502
+ beat,
503
+ atMs: this.now(),
504
+ generation: this.generation,
505
+ waiters,
506
+ };
507
+ void this.drain();
508
+ });
509
+ }
510
+
511
+ private async drain(): Promise<void> {
512
+ if (this.draining) return;
513
+ this.draining = true;
514
+ try {
515
+ while (this.queued) {
516
+ const request = this.queued;
517
+ this.queued = undefined;
518
+ try {
519
+ const render = await this.renderer.render(request.score);
520
+ this.lastRenderMs = render.renderMs;
521
+ // Stopped while rendering: the result is stale, not an error.
522
+ if (request.generation === this.generation) {
523
+ await this.apply(this.toLoop(render, request.score), request);
524
+ this.lastLoopPcm = render.pcm;
525
+ }
526
+ for (const waiter of request.waiters) waiter.resolve();
527
+ } catch (error) {
528
+ for (const waiter of request.waiters)
529
+ waiter.reject(
530
+ error instanceof Error ? error : new Error(String(error)),
531
+ );
532
+ }
533
+ }
534
+ } finally {
535
+ this.draining = false;
536
+ }
537
+ }
538
+
539
+ private async apply(loop: Loop, request: PlayRequest): Promise<void> {
540
+ // The transport kept moving while the render ran.
541
+ const beat =
542
+ request.beat === undefined
543
+ ? undefined
544
+ : request.beat +
545
+ ((this.now() - request.atMs) * this.sampleRate) /
546
+ 1000 /
547
+ loop.framesPerBeat;
276
548
  if (this.child) {
277
549
  this.swap(loop, beat);
278
550
  return;
@@ -303,10 +575,14 @@ export class AudioEngine {
303
575
  * by adding it.
304
576
  */
305
577
  private queuedBeats(loop: Loop): number {
578
+ return this.queuedFrames() / loop.framesPerBeat;
579
+ }
580
+
581
+ private queuedFrames(): number {
306
582
  const elapsed = Math.floor(
307
583
  ((this.now() - this.startMs) * this.sampleRate) / 1000,
308
584
  );
309
- return Math.max(0, this.written - elapsed) / loop.framesPerBeat;
585
+ return Math.max(0, this.written - elapsed);
310
586
  }
311
587
 
312
588
  public stop(): void {
@@ -315,11 +591,20 @@ export class AudioEngine {
315
591
 
316
592
  public async stopAsync(): Promise<void> {
317
593
  this.generation += 1;
594
+ this.lastLoopPcm = undefined;
595
+ this.respawns = 0;
596
+ if (this.respawnTimer) clearTimeout(this.respawnTimer);
597
+ this.respawnTimer = undefined;
318
598
  if (this.fallback) {
319
599
  this.fallback.stop();
320
600
  return;
321
601
  }
322
602
  await this.starting?.catch(() => undefined);
603
+ if (this.monitoring && this.child) {
604
+ // Play mode: the transport stopped, the live player keeps running.
605
+ this.loop = undefined;
606
+ return;
607
+ }
323
608
  if (this.timer) clearInterval(this.timer);
324
609
  this.timer = undefined;
325
610
  const child = this.child;
@@ -337,6 +622,13 @@ export class AudioEngine {
337
622
  await this.lock.release();
338
623
  }
339
624
 
625
+ /** Stops playback and releases the render worker. */
626
+ public async dispose(): Promise<void> {
627
+ this.monitoring = false;
628
+ await this.stopAsync();
629
+ this.renderer.dispose();
630
+ }
631
+
340
632
  /**
341
633
  * Write every frame due by now plus the lead. Called by the timer; tests
342
634
  * call it directly with an injected clock.
@@ -344,22 +636,22 @@ export class AudioEngine {
344
636
  public pump(): void {
345
637
  const child = this.child;
346
638
  const loop = this.loop;
347
- if (!child || !loop) return;
639
+ if (!child || (!loop && !this.monitoring)) return;
348
640
  const due =
349
641
  Math.floor(((this.now() - this.startMs) * this.sampleRate) / 1000) +
350
- this.leadFrames;
642
+ this.lead;
351
643
  let remaining = due - this.written;
352
644
  if (remaining <= 0) return;
353
645
  // Never queue more than one second at once, e.g. after a stalled loop.
354
646
  if (remaining > this.sampleRate) {
355
647
  const skipped = remaining - this.sampleRate;
356
- this.cursor = (this.cursor + skipped) % loop.frames;
648
+ if (loop) this.cursor = (this.cursor + skipped) % loop.frames;
357
649
  this.written += skipped;
358
650
  remaining = this.sampleRate;
359
651
  }
360
652
  const out = new Int16Array(remaining * RENDER_CHANNELS);
361
653
  let offset = 0;
362
- while (offset < remaining) {
654
+ while (loop && offset < remaining) {
363
655
  const take = Math.min(remaining - offset, loop.frames - this.cursor);
364
656
  out.set(
365
657
  loop.pcm.subarray(
@@ -371,6 +663,7 @@ export class AudioEngine {
371
663
  offset += take;
372
664
  this.cursor = (this.cursor + take) % loop.frames;
373
665
  }
666
+ this.mixLive(out, this.written, remaining);
374
667
  this.written += remaining;
375
668
  try {
376
669
  child.stdin.write(new Uint8Array(out.buffer));
@@ -380,14 +673,72 @@ export class AudioEngine {
380
673
  }
381
674
  }
382
675
 
383
- private render(score: TrackScore): Loop {
384
- const audio = renderScorePcm(score, {
385
- sampleRate: this.sampleRate,
386
- loop: true,
387
- });
676
+ /** Wall time (monotonic ms) stream frame `frame` sounds at. */
677
+ private frameMs(frame: number): number {
678
+ return this.startMs + (frame * 1000) / this.sampleRate;
679
+ }
680
+
681
+ /** Add live voices and the click bus to a block about to be written. */
682
+ private mixLive(out: Int16Array, firstFrame: number, frames: number): void {
683
+ if (this.voices.size === 0 && !this.click && this.clickVoices.length === 0)
684
+ return;
685
+ for (const [id, voice] of this.voices) {
686
+ let index = 0;
687
+ for (; index < frames && voice.position < voice.frames; index += 1) {
688
+ let gain = 1;
689
+ if (voice.fadeLeft !== undefined) {
690
+ if (voice.fadeLeft <= 0) break;
691
+ gain = voice.fadeLeft / voice.fadeFrames;
692
+ voice.fadeLeft -= 1;
693
+ }
694
+ const at = index * RENDER_CHANNELS;
695
+ const from = voice.position * RENDER_CHANNELS;
696
+ out[at] = clamp16(out[at]! + voice.pcm[from]! * gain);
697
+ out[at + 1] = clamp16(out[at + 1]! + voice.pcm[from + 1]! * gain);
698
+ voice.position += 1;
699
+ }
700
+ if (
701
+ voice.position >= voice.frames ||
702
+ (voice.fadeLeft !== undefined && voice.fadeLeft <= 0)
703
+ )
704
+ this.voices.delete(id);
705
+ }
706
+ const click = this.click;
707
+ if (click && this.sounds) {
708
+ const from = click.beatAt(this.frameMs(firstFrame));
709
+ const to = click.beatAt(this.frameMs(firstFrame + frames));
710
+ // A stopped transport, a seek or a loop wrap fires no click.
711
+ if (from !== undefined && to !== undefined && to > from && to - from < 2)
712
+ for (const event of clicksIn(from, to, frames, click)) {
713
+ this.clickVoices.push({
714
+ sound: this.sounds[event.level],
715
+ position: -event.offset,
716
+ });
717
+ }
718
+ }
719
+ if (this.clickVoices.length === 0) return;
720
+ const level = 32767 * Math.max(0, Math.min(1, click?.volume ?? 0)) * 0.5;
721
+ for (const voice of this.clickVoices) {
722
+ for (let index = 0; index < frames; index += 1) {
723
+ const position = voice.position + index;
724
+ if (position < 0) continue;
725
+ if (position >= voice.sound.length) break;
726
+ const value = voice.sound[position]! * level;
727
+ const at = index * RENDER_CHANNELS;
728
+ out[at] = clamp16(out[at]! + value);
729
+ out[at + 1] = clamp16(out[at + 1]! + value);
730
+ }
731
+ voice.position += frames;
732
+ }
733
+ this.clickVoices = this.clickVoices.filter(
734
+ (voice) => voice.position < voice.sound.length,
735
+ );
736
+ }
737
+
738
+ private toLoop(render: LoopRender, score: TrackScore): Loop {
388
739
  return {
389
- pcm: audio.pcm,
390
- frames: audio.frames,
740
+ pcm: render.pcm,
741
+ frames: render.frames,
391
742
  framesPerBeat: (60 * this.sampleRate) / score.tempoBpm,
392
743
  };
393
744
  }
@@ -416,7 +767,7 @@ export class AudioEngine {
416
767
  this.loop = next;
417
768
  }
418
769
 
419
- private async start(loop: Loop, beat: number): Promise<void> {
770
+ private async start(loop: Loop | undefined, beat: number): Promise<void> {
420
771
  const generation = this.generation;
421
772
  if (!(await this.lock.acquire())) return;
422
773
  if (generation !== this.generation) {
@@ -435,20 +786,62 @@ export class AudioEngine {
435
786
  this.loop = loop;
436
787
  this.startMs = this.now();
437
788
  this.written = 0;
438
- this.cursor = this.frameForBeat(loop, beat);
789
+ this.cursor = loop ? this.frameForBeat(loop, beat) : 0;
439
790
  void child.exited.then(() => {
440
791
  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();
792
+ this.onPlayerExit(generation);
448
793
  });
449
794
  this.pump();
450
795
  if (this.useTimer) this.timer = setInterval(() => this.pump(), this.tickMs);
451
796
  }
797
+
798
+ /**
799
+ * The player died while we still want sound. Respawn it a bounded number
800
+ * of times, resuming from the frame the listener last heard, and report
801
+ * each attempt; after that, stop cleanly rather than show a silent
802
+ * "playing" transport.
803
+ */
804
+ private onPlayerExit(generation: number): void {
805
+ const loop = this.loop;
806
+ const ranMs = this.now() - this.startMs;
807
+ // Frame the device was playing when the stream ended.
808
+ const heard = loop
809
+ ? (((this.cursor - this.queuedFrames()) % loop.frames) + loop.frames) %
810
+ loop.frames
811
+ : 0;
812
+ const diedAt = this.now();
813
+ this.child = undefined;
814
+ this.loop = undefined;
815
+ if (this.timer) clearInterval(this.timer);
816
+ this.timer = undefined;
817
+ void this.lock.release().then(() => {
818
+ if ((!loop && !this.monitoring) || generation !== this.generation) return;
819
+ if (ranMs >= RESPAWN_RESET_MS) this.respawns = 0;
820
+ if (this.respawns >= MAX_RESPAWNS) {
821
+ this.onStatus?.({
822
+ state: "stopped",
823
+ message: `audio player exited ${MAX_RESPAWNS} times; playback stopped`,
824
+ });
825
+ return;
826
+ }
827
+ this.respawns += 1;
828
+ const delay = this.respawnMs * 2 ** (this.respawns - 1);
829
+ this.onStatus?.({
830
+ state: "restarting",
831
+ message: `audio player exited; restarting (${this.respawns}/${MAX_RESPAWNS})`,
832
+ });
833
+ this.respawnTimer = setTimeout(() => {
834
+ this.respawnTimer = undefined;
835
+ if (generation !== this.generation || this.child || this.starting)
836
+ return;
837
+ const frame = heard + ((this.now() - diedAt) * this.sampleRate) / 1000;
838
+ const beat = loop ? frame / loop.framesPerBeat : 0;
839
+ this.starting = this.start(loop, beat).finally(() => {
840
+ this.starting = undefined;
841
+ });
842
+ }, delay);
843
+ });
844
+ }
452
845
  }
453
846
 
454
847
  function spawnStdinPlayer(command: readonly string[]): PlayerProcess {
@@ -465,3 +858,7 @@ function spawnStdinPlayer(command: readonly string[]): PlayerProcess {
465
858
  kill: () => child.kill(),
466
859
  };
467
860
  }
861
+
862
+ function clamp16(value: number): number {
863
+ return Math.max(-32768, Math.min(32767, Math.round(value)));
864
+ }