@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.
- package/CHANGELOG.md +201 -0
- package/DAWG.md +762 -11
- package/README.md +79 -30
- package/core/chords.ts +1724 -0
- package/core/diff.ts +261 -0
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/key.ts +43 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/pitch.ts +60 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +1054 -14
- package/core/sdk/eval-child.ts +125 -0
- package/core/sdk/eval.ts +260 -0
- package/core/sdk/print.ts +675 -0
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +4159 -0
- package/core/slug.ts +19 -0
- package/core/synth.ts +1001 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +317 -16
- package/src/agent/brief.ts +50 -5
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +55 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +705 -14
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +35 -14
- package/src/agent/xcb.ts +233 -21
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +99 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +301 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +337 -0
- package/src/audio/engine.ts +430 -33
- package/src/audio/kits.ts +200 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/preview.ts +470 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +420 -0
- package/src/audio/samples.ts +1025 -0
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +550 -334
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- 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/drums.ts +299 -0
- package/src/commands/edit.ts +192 -0
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +535 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +34 -15
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +462 -0
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +2136 -170
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +186 -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 +397 -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/media/wavetable.ts +202 -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 +49 -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 +6 -38
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/audition.ts +501 -0
- package/src/tui/euclid.ts +472 -0
- package/src/tui/menu.ts +2125 -0
- package/src/tui/play-chords.ts +538 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +984 -0
- package/src/tui/sketch.ts +108 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +684 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +398 -33
- package/tui/drums.ts +44 -0
- package/tui/grammar.ts +281 -0
- package/tui/highway.ts +24 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +188 -0
package/src/audio/engine.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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.
|
|
216
|
-
* yet written, mapping the current beat
|
|
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
|
-
|
|
223
|
-
private
|
|
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
|
|
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
|
-
|
|
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)
|
|
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.
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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:
|
|
390
|
-
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
|
-
|
|
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
|
+
}
|