@hraness/dawg 0.0.0-stage → 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 +126 -0
- package/DAWG.md +327 -0
- package/LICENSE +21 -0
- package/README.md +213 -2
- package/core/diff.ts +249 -0
- package/core/drums.ts +102 -0
- package/core/key.ts +43 -0
- package/core/loop.ts +78 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +1388 -0
- 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 +45 -4
- package/src/agent/agent.ts +853 -0
- package/src/agent/brief.ts +160 -0
- package/src/agent/gateway.ts +441 -0
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +157 -0
- package/src/agent/planner.ts +259 -0
- package/src/agent/provider.ts +454 -0
- package/src/agent/sse.ts +114 -0
- package/src/agent/tools.ts +1373 -0
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +262 -0
- package/src/agent/xcb.ts +579 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/clock.ts +68 -0
- package/src/audio/engine.ts +841 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/lock.ts +57 -0
- package/src/audio/player.ts +134 -0
- 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 +861 -0
- package/src/auth/cli.ts +231 -0
- package/src/auth/credentials.ts +411 -0
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +1191 -0
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +207 -0
- package/src/auth/tui.ts +107 -0
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +69 -0
- package/src/commands/music.ts +461 -0
- package/src/commands/sample.ts +302 -0
- package/src/daemon.ts +31 -0
- package/src/main.ts +2209 -0
- 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 +121 -0
- package/src/session/attach.ts +181 -0
- package/src/session/client.ts +498 -0
- package/src/session/daemon.ts +740 -0
- package/src/session/delta.ts +249 -0
- package/src/session/list.ts +180 -0
- package/src/session/lock.ts +92 -0
- package/src/session/meta.ts +253 -0
- package/src/session/naming.ts +430 -0
- package/src/session/port.ts +481 -0
- package/src/session/presence.ts +159 -0
- package/src/session/protocol.ts +618 -0
- package/src/session/rebase.ts +168 -0
- package/src/session/store.ts +581 -0
- 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 +364 -0
- package/tui/app.ts +1372 -0
- package/tui/drums.ts +65 -0
- package/tui/highway.ts +921 -0
- package/tui/input.ts +63 -0
- package/tui/keys.ts +102 -0
- package/tui/layers.ts +80 -0
- package/tui/play-strip.ts +143 -0
- package/tui/prompt.ts +609 -0
- package/tui/render.ts +124 -0
- package/tui/screen.ts +247 -0
- package/tui/text.ts +72 -0
- package/tui/theme.ts +451 -0
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
import type { TrackScore } from "../../core/score.ts";
|
|
2
|
+
import { PlaybackLock } from "./lock.ts";
|
|
3
|
+
import { LoopPlayer } from "./player.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";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* How dawgd (or a file-mode window) makes sound.
|
|
11
|
+
*
|
|
12
|
+
* - `ffplay` and `sox` are streaming backends: one long-lived player reads
|
|
13
|
+
* raw interleaved s16le stereo PCM from stdin, and the engine writes the
|
|
14
|
+
* loop into it paced by the wall clock. Edits swap the loop buffer at the
|
|
15
|
+
* current phase, so playback never restarts and stays sample-continuous.
|
|
16
|
+
* - `afplay` is the last fallback: re-render a WAV and restart the one-shot
|
|
17
|
+
* player on every edit (the pre-engine behavior), so edits cause a gap.
|
|
18
|
+
* - `command` is an explicit DAWG_AUDIO_PLAYER override that also reads
|
|
19
|
+
* s16le from stdin; tests use it to record the exact byte stream.
|
|
20
|
+
* - `none` makes no sound; the transport clock still runs.
|
|
21
|
+
*/
|
|
22
|
+
export type AudioBackend = "ffplay" | "sox" | "afplay" | "command" | "none";
|
|
23
|
+
|
|
24
|
+
export type AudioBackendInfo = Readonly<{
|
|
25
|
+
backend: AudioBackend;
|
|
26
|
+
/** True for gapless stdin-streaming backends. */
|
|
27
|
+
streaming: boolean;
|
|
28
|
+
/** argv with `{rate}` / `{channels}` already substituted, if streaming. */
|
|
29
|
+
command?: readonly string[];
|
|
30
|
+
/** Human-readable reason the backend was chosen. */
|
|
31
|
+
detail: string;
|
|
32
|
+
}>;
|
|
33
|
+
|
|
34
|
+
type Which = (binary: string) => string | null;
|
|
35
|
+
|
|
36
|
+
export type DetectOptions = Readonly<{
|
|
37
|
+
env?: Readonly<Record<string, string | undefined>>;
|
|
38
|
+
which?: Which;
|
|
39
|
+
platform?: NodeJS.Platform;
|
|
40
|
+
sampleRate?: number;
|
|
41
|
+
}>;
|
|
42
|
+
|
|
43
|
+
/** Streaming argv for the known stdin players. */
|
|
44
|
+
export function streamingCommand(
|
|
45
|
+
backend: "ffplay" | "sox",
|
|
46
|
+
binary: string,
|
|
47
|
+
sampleRate: number,
|
|
48
|
+
): string[] {
|
|
49
|
+
if (backend === "ffplay")
|
|
50
|
+
return [
|
|
51
|
+
binary,
|
|
52
|
+
"-nodisp",
|
|
53
|
+
"-hide_banner",
|
|
54
|
+
"-loglevel",
|
|
55
|
+
"quiet",
|
|
56
|
+
"-fflags",
|
|
57
|
+
"nobuffer",
|
|
58
|
+
"-probesize",
|
|
59
|
+
"32",
|
|
60
|
+
"-analyzeduration",
|
|
61
|
+
"0",
|
|
62
|
+
"-f",
|
|
63
|
+
"s16le",
|
|
64
|
+
"-ar",
|
|
65
|
+
String(sampleRate),
|
|
66
|
+
"-ch_layout",
|
|
67
|
+
"stereo",
|
|
68
|
+
"-i",
|
|
69
|
+
"-",
|
|
70
|
+
];
|
|
71
|
+
return [
|
|
72
|
+
binary,
|
|
73
|
+
"-q",
|
|
74
|
+
"-t",
|
|
75
|
+
"raw",
|
|
76
|
+
"-r",
|
|
77
|
+
String(sampleRate),
|
|
78
|
+
"-e",
|
|
79
|
+
"signed-integer",
|
|
80
|
+
"-b",
|
|
81
|
+
"16",
|
|
82
|
+
"-c",
|
|
83
|
+
String(RENDER_CHANNELS),
|
|
84
|
+
"-L",
|
|
85
|
+
"-",
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Pick the audio backend. Order: DAWG_AUDIO=0 (none), DAWG_AUDIO_PLAYER
|
|
91
|
+
* (explicit stdin command), DAWG_AUDIO_BACKEND (forced name), then ffplay,
|
|
92
|
+
* sox `play`, and afplay (macOS) in that order.
|
|
93
|
+
*/
|
|
94
|
+
export function detectAudioBackend(
|
|
95
|
+
options: DetectOptions = {},
|
|
96
|
+
): AudioBackendInfo {
|
|
97
|
+
const env = options.env ?? process.env;
|
|
98
|
+
const which: Which = options.which ?? ((binary) => Bun.which(binary));
|
|
99
|
+
const platform = options.platform ?? process.platform;
|
|
100
|
+
const sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;
|
|
101
|
+
if (env.DAWG_AUDIO === "0")
|
|
102
|
+
return { backend: "none", streaming: false, detail: "DAWG_AUDIO=0" };
|
|
103
|
+
const custom = env.DAWG_AUDIO_PLAYER?.trim();
|
|
104
|
+
if (custom) {
|
|
105
|
+
const command = parseCommand(custom).map((part) =>
|
|
106
|
+
part
|
|
107
|
+
.replaceAll("{rate}", String(sampleRate))
|
|
108
|
+
.replaceAll("{channels}", String(RENDER_CHANNELS)),
|
|
109
|
+
);
|
|
110
|
+
if (command.length > 0)
|
|
111
|
+
return {
|
|
112
|
+
backend: "command",
|
|
113
|
+
streaming: true,
|
|
114
|
+
command,
|
|
115
|
+
detail: "DAWG_AUDIO_PLAYER",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const forced = env.DAWG_AUDIO_BACKEND?.trim().toLowerCase();
|
|
119
|
+
const candidates: AudioBackend[] =
|
|
120
|
+
forced === "ffplay" ||
|
|
121
|
+
forced === "sox" ||
|
|
122
|
+
forced === "afplay" ||
|
|
123
|
+
forced === "none"
|
|
124
|
+
? [forced]
|
|
125
|
+
: ["ffplay", "sox", "afplay"];
|
|
126
|
+
for (const candidate of candidates) {
|
|
127
|
+
if (candidate === "none")
|
|
128
|
+
return {
|
|
129
|
+
backend: "none",
|
|
130
|
+
streaming: false,
|
|
131
|
+
detail: "DAWG_AUDIO_BACKEND",
|
|
132
|
+
};
|
|
133
|
+
if (candidate === "ffplay" || candidate === "sox") {
|
|
134
|
+
const binary = which(candidate === "ffplay" ? "ffplay" : "play");
|
|
135
|
+
if (!binary) continue;
|
|
136
|
+
return {
|
|
137
|
+
backend: candidate,
|
|
138
|
+
streaming: true,
|
|
139
|
+
command: streamingCommand(candidate, binary, sampleRate),
|
|
140
|
+
detail: `${binary} (gapless stream)`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const binary = platform === "darwin" ? which("afplay") : null;
|
|
144
|
+
if (binary)
|
|
145
|
+
return {
|
|
146
|
+
backend: "afplay",
|
|
147
|
+
streaming: false,
|
|
148
|
+
detail: `${binary} (re-render on edit; install ffmpeg or sox for gapless playback)`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
backend: "none",
|
|
153
|
+
streaming: false,
|
|
154
|
+
detail: "no player found (install ffmpeg or sox)",
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** One status line for `dawg auth status` and the header. */
|
|
159
|
+
export function audioStatusLine(info = detectAudioBackend()): string {
|
|
160
|
+
return `audio: ${info.backend} · ${info.detail}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Splits a command string on whitespace, honoring simple quotes. */
|
|
164
|
+
function parseCommand(value: string): string[] {
|
|
165
|
+
const parts: string[] = [];
|
|
166
|
+
const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
167
|
+
for (const match of value.matchAll(pattern))
|
|
168
|
+
parts.push(match[1] ?? match[2] ?? match[3]!);
|
|
169
|
+
return parts;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Swaps re-anchor to the transport only past this disagreement. */
|
|
173
|
+
const RESYNC_SECONDS = 0.03;
|
|
174
|
+
|
|
175
|
+
type Sink = {
|
|
176
|
+
write(chunk: Uint8Array): unknown;
|
|
177
|
+
flush?(): unknown;
|
|
178
|
+
end?(): unknown;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
type PlayerProcess = {
|
|
182
|
+
readonly pid: number;
|
|
183
|
+
readonly stdin: Sink;
|
|
184
|
+
readonly exited: Promise<number>;
|
|
185
|
+
kill(): void;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export type AudioEngineOptions = Readonly<{
|
|
189
|
+
lockPath?: string;
|
|
190
|
+
info?: AudioBackendInfo;
|
|
191
|
+
sampleRate?: number;
|
|
192
|
+
/** Frames written ahead of the wall clock. Bounds the player queue. */
|
|
193
|
+
leadMs?: number;
|
|
194
|
+
/** Pump interval. */
|
|
195
|
+
tickMs?: number;
|
|
196
|
+
/** Monotonic milliseconds. Injected by tests to drive the pump. */
|
|
197
|
+
now?: () => number;
|
|
198
|
+
/** Set false to drive `pump()` manually (tests). */
|
|
199
|
+
timer?: boolean;
|
|
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;
|
|
209
|
+
}>;
|
|
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
|
+
|
|
224
|
+
type Loop = Readonly<{
|
|
225
|
+
pcm: Int16Array;
|
|
226
|
+
frames: number;
|
|
227
|
+
framesPerBeat: number;
|
|
228
|
+
}>;
|
|
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
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The gapless audio engine. It renders the score into one seamless loop
|
|
264
|
+
* (tails folded onto the start) and streams it to a long-lived stdin player.
|
|
265
|
+
*
|
|
266
|
+
* Timing: the engine is paced by the same monotonic clock as the transport.
|
|
267
|
+
* Frame `n` of the stream belongs to wall time `start + n / rate`, and the
|
|
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
|
|
270
|
+
* plays the queue at its own rate, so what you hear trails the transport by
|
|
271
|
+
* the player's fixed output latency and never accumulates drift from
|
|
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.
|
|
277
|
+
*/
|
|
278
|
+
export class AudioEngine {
|
|
279
|
+
public readonly info: AudioBackendInfo;
|
|
280
|
+
private readonly lock: PlaybackLock;
|
|
281
|
+
public readonly sampleRate: number;
|
|
282
|
+
private leadFrames: number;
|
|
283
|
+
private readonly defaultLeadFrames: number;
|
|
284
|
+
private readonly tickMs: number;
|
|
285
|
+
private readonly now: () => number;
|
|
286
|
+
private readonly useTimer: boolean;
|
|
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;
|
|
291
|
+
private fallback: LoopPlayer | undefined;
|
|
292
|
+
private child: PlayerProcess | undefined;
|
|
293
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
294
|
+
private loop: Loop | undefined;
|
|
295
|
+
/** Monotonic ms that stream frame 0 belongs to. */
|
|
296
|
+
private startMs = 0;
|
|
297
|
+
/** Stream frames written since `startMs`. */
|
|
298
|
+
private written = 0;
|
|
299
|
+
/** Loop frame that stream frame `written` reads next (fractional-free). */
|
|
300
|
+
private cursor = 0;
|
|
301
|
+
private starting: Promise<void> | undefined;
|
|
302
|
+
private generation = 0;
|
|
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;
|
|
315
|
+
|
|
316
|
+
public constructor(options: AudioEngineOptions = {}) {
|
|
317
|
+
this.sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;
|
|
318
|
+
this.info =
|
|
319
|
+
options.info ?? detectAudioBackend({ sampleRate: this.sampleRate });
|
|
320
|
+
this.lock = new PlaybackLock(options.lockPath);
|
|
321
|
+
this.leadFrames = Math.round(
|
|
322
|
+
((options.leadMs ?? 200) * this.sampleRate) / 1000,
|
|
323
|
+
);
|
|
324
|
+
this.defaultLeadFrames = this.leadFrames;
|
|
325
|
+
this.tickMs = options.tickMs ?? 20;
|
|
326
|
+
this.now = options.now ?? (() => performance.now());
|
|
327
|
+
this.useTimer = options.timer ?? true;
|
|
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
|
+
});
|
|
338
|
+
if (this.info.backend === "afplay")
|
|
339
|
+
this.fallback = new LoopPlayer(options.lockPath, options.projectRoot);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Whether a streaming player process is currently running. */
|
|
343
|
+
public get streaming(): boolean {
|
|
344
|
+
return this.child !== undefined;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Player processes started so far (gapless edits never add one). */
|
|
348
|
+
public get playerStarts(): number {
|
|
349
|
+
return this.spawns;
|
|
350
|
+
}
|
|
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
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Start playback at `beat`, or, when already playing, swap in the new
|
|
471
|
+
* score at the current position without restarting the player. Resolves
|
|
472
|
+
* once the render has been applied (or superseded by a newer one).
|
|
473
|
+
*/
|
|
474
|
+
public play(score: TrackScore, beat?: number): Promise<void> {
|
|
475
|
+
if (this.info.backend === "none") return Promise.resolve();
|
|
476
|
+
if (this.fallback) return this.fallback.play(score, beat);
|
|
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;
|
|
526
|
+
if (this.child) {
|
|
527
|
+
this.swap(loop, beat);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (this.starting) {
|
|
531
|
+
await this.starting;
|
|
532
|
+
if (this.child) this.swap(loop, beat);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
this.starting = this.start(loop, beat ?? 0).finally(() => {
|
|
536
|
+
this.starting = undefined;
|
|
537
|
+
});
|
|
538
|
+
await this.starting;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Jump to transport `beat` (as of now) without restarting the player. */
|
|
542
|
+
public seek(beat: number): void {
|
|
543
|
+
if (!this.loop || !this.child) return;
|
|
544
|
+
this.cursor = this.frameForBeat(
|
|
545
|
+
this.loop,
|
|
546
|
+
beat + this.queuedBeats(this.loop),
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Beats already queued ahead of the wall clock. The next frame written
|
|
552
|
+
* sounds this far after now, so a transport beat maps to the write head
|
|
553
|
+
* by adding it.
|
|
554
|
+
*/
|
|
555
|
+
private queuedBeats(loop: Loop): number {
|
|
556
|
+
return this.queuedFrames() / loop.framesPerBeat;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private queuedFrames(): number {
|
|
560
|
+
const elapsed = Math.floor(
|
|
561
|
+
((this.now() - this.startMs) * this.sampleRate) / 1000,
|
|
562
|
+
);
|
|
563
|
+
return Math.max(0, this.written - elapsed);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
public stop(): void {
|
|
567
|
+
void this.stopAsync();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
public async stopAsync(): Promise<void> {
|
|
571
|
+
this.generation += 1;
|
|
572
|
+
this.respawns = 0;
|
|
573
|
+
if (this.respawnTimer) clearTimeout(this.respawnTimer);
|
|
574
|
+
this.respawnTimer = undefined;
|
|
575
|
+
if (this.fallback) {
|
|
576
|
+
this.fallback.stop();
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
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
|
+
}
|
|
585
|
+
if (this.timer) clearInterval(this.timer);
|
|
586
|
+
this.timer = undefined;
|
|
587
|
+
const child = this.child;
|
|
588
|
+
this.child = undefined;
|
|
589
|
+
this.loop = undefined;
|
|
590
|
+
if (child) {
|
|
591
|
+
try {
|
|
592
|
+
child.stdin.end?.();
|
|
593
|
+
} catch {
|
|
594
|
+
/* already closed */
|
|
595
|
+
}
|
|
596
|
+
child.kill();
|
|
597
|
+
await child.exited.catch(() => undefined);
|
|
598
|
+
}
|
|
599
|
+
await this.lock.release();
|
|
600
|
+
}
|
|
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
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Write every frame due by now plus the lead. Called by the timer; tests
|
|
611
|
+
* call it directly with an injected clock.
|
|
612
|
+
*/
|
|
613
|
+
public pump(): void {
|
|
614
|
+
const child = this.child;
|
|
615
|
+
const loop = this.loop;
|
|
616
|
+
if (!child || (!loop && !this.monitoring)) return;
|
|
617
|
+
const due =
|
|
618
|
+
Math.floor(((this.now() - this.startMs) * this.sampleRate) / 1000) +
|
|
619
|
+
this.lead;
|
|
620
|
+
let remaining = due - this.written;
|
|
621
|
+
if (remaining <= 0) return;
|
|
622
|
+
// Never queue more than one second at once, e.g. after a stalled loop.
|
|
623
|
+
if (remaining > this.sampleRate) {
|
|
624
|
+
const skipped = remaining - this.sampleRate;
|
|
625
|
+
if (loop) this.cursor = (this.cursor + skipped) % loop.frames;
|
|
626
|
+
this.written += skipped;
|
|
627
|
+
remaining = this.sampleRate;
|
|
628
|
+
}
|
|
629
|
+
const out = new Int16Array(remaining * RENDER_CHANNELS);
|
|
630
|
+
let offset = 0;
|
|
631
|
+
while (loop && offset < remaining) {
|
|
632
|
+
const take = Math.min(remaining - offset, loop.frames - this.cursor);
|
|
633
|
+
out.set(
|
|
634
|
+
loop.pcm.subarray(
|
|
635
|
+
this.cursor * RENDER_CHANNELS,
|
|
636
|
+
(this.cursor + take) * RENDER_CHANNELS,
|
|
637
|
+
),
|
|
638
|
+
offset * RENDER_CHANNELS,
|
|
639
|
+
);
|
|
640
|
+
offset += take;
|
|
641
|
+
this.cursor = (this.cursor + take) % loop.frames;
|
|
642
|
+
}
|
|
643
|
+
this.mixLive(out, this.written, remaining);
|
|
644
|
+
this.written += remaining;
|
|
645
|
+
try {
|
|
646
|
+
child.stdin.write(new Uint8Array(out.buffer));
|
|
647
|
+
child.stdin.flush?.();
|
|
648
|
+
} catch {
|
|
649
|
+
/* the exit handler decides whether to restart */
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
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 {
|
|
716
|
+
return {
|
|
717
|
+
pcm: render.pcm,
|
|
718
|
+
frames: render.frames,
|
|
719
|
+
framesPerBeat: (60 * this.sampleRate) / score.tempoBpm,
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
private frameForBeat(loop: Loop, beat: number): number {
|
|
724
|
+
const frame = Math.round(Math.max(0, beat) * loop.framesPerBeat);
|
|
725
|
+
return ((frame % loop.frames) + loop.frames) % loop.frames;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
private swap(next: Loop, beat?: number): void {
|
|
729
|
+
const previous = this.loop;
|
|
730
|
+
// Keep the musical position: the same beat inside the new loop.
|
|
731
|
+
let cursor = previous
|
|
732
|
+
? this.frameForBeat(next, this.cursor / previous.framesPerBeat)
|
|
733
|
+
: 0;
|
|
734
|
+
if (beat !== undefined) {
|
|
735
|
+
// Re-anchor to the transport only when it disagrees audibly (a seek
|
|
736
|
+
// or a tempo change); sub-frame rounding must not click on edits.
|
|
737
|
+
const anchored = this.frameForBeat(next, beat + this.queuedBeats(next));
|
|
738
|
+
const distance = Math.abs(anchored - cursor);
|
|
739
|
+
const wrapped = Math.min(distance, next.frames - distance);
|
|
740
|
+
if (!previous || wrapped > this.sampleRate * RESYNC_SECONDS)
|
|
741
|
+
cursor = anchored;
|
|
742
|
+
}
|
|
743
|
+
this.cursor = cursor;
|
|
744
|
+
this.loop = next;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
private async start(loop: Loop | undefined, beat: number): Promise<void> {
|
|
748
|
+
const generation = this.generation;
|
|
749
|
+
if (!(await this.lock.acquire())) return;
|
|
750
|
+
if (generation !== this.generation) {
|
|
751
|
+
await this.lock.release();
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
let child: PlayerProcess;
|
|
755
|
+
try {
|
|
756
|
+
child = this.spawnPlayer(this.info.command ?? []);
|
|
757
|
+
} catch {
|
|
758
|
+
await this.lock.release();
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
this.spawns += 1;
|
|
762
|
+
this.child = child;
|
|
763
|
+
this.loop = loop;
|
|
764
|
+
this.startMs = this.now();
|
|
765
|
+
this.written = 0;
|
|
766
|
+
this.cursor = loop ? this.frameForBeat(loop, beat) : 0;
|
|
767
|
+
void child.exited.then(() => {
|
|
768
|
+
if (this.child !== child) return;
|
|
769
|
+
this.onPlayerExit(generation);
|
|
770
|
+
});
|
|
771
|
+
this.pump();
|
|
772
|
+
if (this.useTimer) this.timer = setInterval(() => this.pump(), this.tickMs);
|
|
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
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function spawnStdinPlayer(command: readonly string[]): PlayerProcess {
|
|
825
|
+
if (command.length === 0) throw new Error("no player command");
|
|
826
|
+
const child = Bun.spawn([...command], {
|
|
827
|
+
stdin: "pipe",
|
|
828
|
+
stdout: "ignore",
|
|
829
|
+
stderr: "ignore",
|
|
830
|
+
});
|
|
831
|
+
return {
|
|
832
|
+
pid: child.pid,
|
|
833
|
+
stdin: child.stdin,
|
|
834
|
+
exited: child.exited,
|
|
835
|
+
kill: () => child.kill(),
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function clamp16(value: number): number {
|
|
840
|
+
return Math.max(-32768, Math.min(32767, Math.round(value)));
|
|
841
|
+
}
|