@hraness/dawg 0.6.1 → 0.8.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 +118 -0
- package/DAWG.md +454 -140
- package/README.md +30 -26
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +271 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +184 -104
- package/core/expression.ts +15 -0
- package/core/fx.ts +99 -29
- package/core/ids.ts +52 -0
- package/core/instruments.ts +19 -0
- package/core/keys.ts +3 -3
- package/core/loop.ts +8 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/range.ts +581 -0
- package/core/resonators.ts +16 -2
- package/core/routing.ts +165 -0
- package/core/score.ts +955 -14
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +287 -5
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +2132 -46
- package/core/sections.ts +480 -35
- package/core/sing.ts +815 -0
- package/core/style-provenance.ts +80 -0
- package/core/styles/africa-mena-southasia.ts +2893 -0
- package/core/styles/americas.ts +3810 -0
- package/core/styles/art.ts +4993 -0
- package/core/styles/base.ts +123 -0
- package/core/styles/cycles.ts +106 -0
- package/core/styles/electronic.ts +2723 -0
- package/core/styles/europe-asia-pacific.ts +2838 -0
- package/core/styles/excerpt.ts +29 -0
- package/core/styles/gamelan.ts +283 -0
- package/core/styles/generate.ts +2199 -0
- package/core/styles/index.ts +515 -0
- package/core/styles/parts.ts +106 -0
- package/core/styles/pop.ts +3189 -0
- package/core/styles/rock.ts +2993 -0
- package/core/styles/roots.ts +4175 -0
- package/core/styles/schema.ts +429 -0
- package/core/styles/taxonomy.ts +940 -0
- package/core/styles/validate.ts +528 -0
- package/core/tempo.ts +32 -2
- package/core/tuning.ts +19 -3
- package/core/vocoder.ts +524 -0
- package/guides/agent.md +29 -0
- package/guides/arrange.md +31 -0
- package/guides/audio.md +31 -0
- package/guides/audition.md +20 -14
- package/guides/automation.md +12 -7
- package/guides/chords.md +15 -15
- package/guides/effects.md +17 -16
- package/guides/faders.md +20 -16
- package/guides/files.md +13 -9
- package/guides/getting-started.md +15 -11
- package/guides/keys.md +19 -15
- package/guides/media.md +17 -12
- package/guides/mix.md +15 -7
- package/guides/music.md +23 -8
- package/guides/notes.md +15 -9
- package/guides/panes.md +32 -0
- package/guides/performance.md +15 -13
- package/guides/play.md +21 -13
- package/guides/project.md +26 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +15 -9
- package/guides/rhythm.md +18 -13
- package/guides/sessions.md +17 -7
- package/guides/show-me.md +31 -0
- package/guides/sound.md +25 -9
- package/guides/sounds.md +15 -13
- package/guides/styles.md +31 -0
- package/guides/tape.md +32 -0
- package/guides/tempo.md +15 -10
- package/guides/tracks.md +15 -10
- package/guides/tuning.md +32 -0
- package/guides/voice.md +31 -0
- package/guides/web-search.md +18 -8
- package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
- package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
- package/native/prebuilt/manifest.json +21 -0
- package/package.json +6 -2
- package/src/agent/agent.ts +130 -18
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/clip-tools.ts +453 -0
- package/src/agent/command-agent.ts +378 -0
- package/src/agent/drum-tools.ts +2 -2
- package/src/agent/expression-tools.ts +1 -1
- package/src/agent/gateway.ts +246 -60
- package/src/agent/models.ts +53 -12
- package/src/agent/ops.ts +27 -1
- package/src/agent/pack-tools.ts +1 -1
- package/src/agent/planner.ts +25 -0
- package/src/agent/portable-schema.ts +80 -0
- package/src/agent/preview-tool.ts +4 -1
- package/src/agent/provider.ts +22 -8
- package/src/agent/range-tools.ts +216 -0
- package/src/agent/rhythm-tools.ts +1 -1
- package/src/agent/section-tools.ts +1 -1
- package/src/agent/show-me.ts +497 -0
- package/src/agent/steer.ts +15 -0
- package/src/agent/style-tools.ts +217 -0
- package/src/agent/tool-error.ts +12 -0
- package/src/agent/tools.ts +110 -23
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +13 -10
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +49 -5
- package/src/audio/audio-command.ts +218 -0
- package/src/audio/autotune-engine.ts +101 -0
- package/src/audio/autotune.ts +640 -0
- package/src/audio/clips.ts +240 -0
- package/src/audio/devices.ts +264 -0
- package/src/audio/doctor.ts +157 -0
- package/src/audio/dsp/bandbank.ts +138 -0
- package/src/audio/dsp/envelope.ts +10 -0
- package/src/audio/dsp/follow.ts +120 -0
- package/src/audio/dsp/formant.ts +427 -0
- package/src/audio/dsp/glottal.ts +243 -0
- package/src/audio/dsp/interp.ts +7 -2
- package/src/audio/dsp/lpc.ts +50 -0
- package/src/audio/dsp/periodicity.ts +59 -0
- package/src/audio/dsp/pitch.ts +995 -0
- package/src/audio/dsp/psola.ts +199 -0
- package/src/audio/effects/chain.ts +3 -1
- package/src/audio/effects/common.ts +43 -0
- package/src/audio/effects/convolution.ts +7 -4
- package/src/audio/effects/filter.ts +48 -69
- package/src/audio/effects/formant.ts +263 -0
- package/src/audio/engine.ts +360 -40
- package/src/audio/fit.ts +35 -3
- package/src/audio/instrument-check.ts +59 -43
- package/src/audio/instruments.ts +4 -0
- package/src/audio/keys/calibration.ts +56 -0
- package/src/audio/keys/electric.ts +8 -1
- package/src/audio/keys/engine.ts +13 -1
- package/src/audio/keys/piano.ts +22 -2
- package/src/audio/kits.ts +135 -6
- package/src/audio/live.ts +114 -20
- package/src/audio/native.ts +615 -0
- package/src/audio/preview.ts +30 -2
- package/src/audio/render-worker.ts +2 -0
- package/src/audio/renderer.ts +2 -0
- package/src/audio/resample.ts +2 -1
- package/src/audio/sampler.ts +85 -4
- package/src/audio/samples.ts +20 -2
- package/src/audio/sing/analysis.ts +193 -0
- package/src/audio/sing/engine.ts +949 -0
- package/src/audio/strings/bow.ts +48 -5
- package/src/audio/strings/engine.ts +8 -2
- package/src/audio/synth/oscillators.ts +31 -21
- package/src/audio/synth/voice.ts +34 -1
- package/src/audio/vocoder/bank.ts +314 -0
- package/src/audio/vocoder/carrier.ts +165 -0
- package/src/audio/vocoder/control.ts +68 -0
- package/src/audio/vocoder/detect.ts +50 -0
- package/src/audio/vocoder/index.ts +304 -0
- package/src/audio/vocoder/talkbox.ts +143 -0
- package/src/audio/wav.ts +500 -77
- package/src/audio/winds/engine.ts +5 -1
- package/src/audio/winds/trim.ts +28 -4
- package/src/audio/winds/trims1.ts +297 -0
- package/src/audio/winds/voice.ts +15 -2
- package/src/auth/cli.ts +38 -36
- package/src/auth/credentials.ts +30 -1
- package/src/auth/login.ts +15 -9
- package/src/auth/tui.ts +19 -10
- package/src/commands/arrange.ts +82 -32
- package/src/commands/autotune.ts +421 -0
- package/src/commands/calibration.ts +74 -0
- package/src/commands/clips.ts +887 -0
- package/src/commands/drums.ts +3 -2
- package/src/commands/edit.ts +11 -4
- package/src/commands/expression.ts +1 -1
- package/src/commands/formant.ts +221 -0
- package/src/commands/fx.ts +101 -32
- package/src/commands/grammar.ts +560 -0
- package/src/commands/help.ts +774 -366
- package/src/commands/history.ts +139 -10
- package/src/commands/keys.ts +25 -8
- package/src/commands/modal.ts +1 -1
- package/src/commands/music.ts +1 -1
- package/src/commands/nearest.ts +53 -0
- package/src/commands/pack.ts +9 -2
- package/src/commands/param-range.ts +56 -0
- package/src/commands/parses.ts +135 -0
- package/src/commands/progression.ts +170 -0
- package/src/commands/range.ts +763 -0
- package/src/commands/resample.ts +13 -10
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +3 -24
- package/src/commands/sample.ts +11 -1
- package/src/commands/sing.ts +474 -0
- package/src/commands/strum.ts +13 -1
- package/src/commands/style.ts +415 -0
- package/src/commands/time.ts +6 -3
- package/src/commands/tuning.ts +3 -3
- package/src/commands/vocal-pitch.ts +617 -0
- package/src/commands/vocal.ts +147 -0
- package/src/commands/vocoder.ts +627 -0
- package/src/commands/wind.ts +2 -2
- package/src/fs/durable.ts +50 -0
- package/src/identity/actor.ts +127 -0
- package/src/lang/glossary.ts +511 -0
- package/src/launch-args.ts +267 -0
- package/src/main.ts +2508 -308
- package/src/media/cli.ts +20 -3
- package/src/media/import.ts +3 -1
- package/src/project/check.ts +21 -2
- package/src/project/clip-pins.ts +72 -0
- package/src/project/init.ts +23 -8
- package/src/project/sync.ts +418 -86
- package/src/render.ts +20 -1
- package/src/session/client.ts +107 -3
- package/src/session/clipboard.ts +79 -0
- package/src/session/daemon.ts +199 -5
- package/src/session/live-host.ts +232 -0
- package/src/session/meta.ts +14 -0
- package/src/session/origin.ts +174 -0
- package/src/session/port.ts +109 -14
- package/src/session/presence.ts +34 -4
- package/src/session/protocol.ts +365 -7
- package/src/session/rebase.ts +63 -12
- package/src/session/receipt.ts +265 -0
- package/src/session/shared-live.ts +131 -0
- package/src/session/store.ts +126 -45
- package/src/tui/arrange-menu.ts +225 -23
- package/src/tui/audition.ts +1 -1
- package/src/tui/euclid.ts +113 -27
- package/src/tui/fader.ts +282 -42
- package/src/tui/granular-menu.ts +2 -4
- package/src/tui/knob-fields.ts +107 -0
- package/src/tui/knob-map.ts +198 -0
- package/src/tui/menu-clips.ts +297 -0
- package/src/tui/menu-time.ts +20 -13
- package/src/tui/menu-voice.ts +405 -0
- package/src/tui/menu.ts +987 -179
- package/src/tui/modal-menu.ts +6 -6
- package/src/tui/performance-menu.ts +5 -2
- package/src/tui/play-chords.ts +4 -2
- package/src/tui/play-mode.ts +15 -1
- package/src/tui/play-session.ts +243 -28
- package/src/tui/sing-menu.ts +278 -0
- package/src/tui/style-menu.ts +104 -0
- package/src/tui/tape-mode.ts +580 -0
- package/src/tui/tape-view.ts +263 -0
- package/src/tui/vocoder-menu.ts +244 -0
- package/src/tui/wind-menu.ts +3 -3
- package/src/version.ts +8 -0
- package/src/web/fetch.ts +115 -29
- package/tui/activity.ts +180 -9
- package/tui/app.ts +491 -84
- package/tui/clip-row.ts +132 -0
- package/tui/delight.ts +193 -0
- package/tui/drawer.ts +233 -27
- package/tui/frame-gate.ts +76 -0
- package/tui/grammar.ts +143 -65
- package/tui/guide.ts +50 -5
- package/tui/highway.ts +309 -30
- package/tui/hints.ts +197 -0
- package/tui/hits.ts +7 -1
- package/tui/input.ts +60 -9
- package/tui/keys.ts +1 -1
- package/tui/knobs.ts +268 -0
- package/tui/play-strip.ts +80 -14
- package/tui/prompt.ts +1 -1
- package/tui/screen.ts +151 -18
- package/tui/tape.ts +439 -0
- package/tui/text.ts +35 -2
- package/tui/theme.ts +46 -1
package/src/audio/engine.ts
CHANGED
|
@@ -14,8 +14,22 @@ import { measureLoudness, pcmChannels } from "./loudness.ts";
|
|
|
14
14
|
import type { MasterReport } from "./master.ts";
|
|
15
15
|
import { levelOf, type SoundLevel } from "./preview.ts";
|
|
16
16
|
import { DEFAULT_SAMPLE_RATE, RENDER_CHANNELS } from "./wav.ts";
|
|
17
|
+
import { statSync } from "node:fs";
|
|
18
|
+
import {
|
|
19
|
+
audioChoicePath,
|
|
20
|
+
readAudioChoice,
|
|
21
|
+
resolveOutputDevice,
|
|
22
|
+
} from "./devices.ts";
|
|
17
23
|
import { playbackTime } from "./arrange.ts";
|
|
18
24
|
import { transportMapFor, type TransportMap } from "./clock.ts";
|
|
25
|
+
import {
|
|
26
|
+
openNativePlayer,
|
|
27
|
+
probeNativeSink,
|
|
28
|
+
type AudioTiming,
|
|
29
|
+
type NativeProbe,
|
|
30
|
+
type PlayerClock,
|
|
31
|
+
type SinkLibrary,
|
|
32
|
+
} from "./native.ts";
|
|
19
33
|
|
|
20
34
|
/**
|
|
21
35
|
* How dawgd (or a file-mode window) makes sound.
|
|
@@ -30,7 +44,8 @@ import { transportMapFor, type TransportMap } from "./clock.ts";
|
|
|
30
44
|
* s16le from stdin; tests use it to record the exact byte stream.
|
|
31
45
|
* - `none` makes no sound; the transport clock still runs.
|
|
32
46
|
*/
|
|
33
|
-
export type AudioBackend =
|
|
47
|
+
export type AudioBackend =
|
|
48
|
+
"native" | "ffplay" | "sox" | "afplay" | "command" | "none";
|
|
34
49
|
|
|
35
50
|
export type AudioBackendInfo = Readonly<{
|
|
36
51
|
backend: AudioBackend;
|
|
@@ -40,6 +55,10 @@ export type AudioBackendInfo = Readonly<{
|
|
|
40
55
|
command?: readonly string[];
|
|
41
56
|
/** Human-readable reason the backend was chosen. */
|
|
42
57
|
detail: string;
|
|
58
|
+
/** The loaded sink, for the `native` backend. */
|
|
59
|
+
native?: SinkLibrary;
|
|
60
|
+
/** Why the native sink is not in use, when another backend was picked. */
|
|
61
|
+
nativeUnavailable?: string;
|
|
43
62
|
}>;
|
|
44
63
|
|
|
45
64
|
type Which = (binary: string) => string | null;
|
|
@@ -49,8 +68,26 @@ export type DetectOptions = Readonly<{
|
|
|
49
68
|
which?: Which;
|
|
50
69
|
platform?: NodeJS.Platform;
|
|
51
70
|
sampleRate?: number;
|
|
71
|
+
/** Native sink probe; `false` skips it (tests). Default: the real one. */
|
|
72
|
+
native?: (() => NativeProbe) | false;
|
|
52
73
|
}>;
|
|
53
74
|
|
|
75
|
+
let cachedProbe: NativeProbe | undefined;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Replace the process's native probe (a test preload's fake sink, so a PTY
|
|
79
|
+
* run can list and switch devices without hardware).
|
|
80
|
+
*/
|
|
81
|
+
export function setNativeProbe(probe: NativeProbe | undefined): void {
|
|
82
|
+
cachedProbe = probe;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The real probe, once per process (dlopen is not repeated). */
|
|
86
|
+
function defaultNativeProbe(): NativeProbe {
|
|
87
|
+
cachedProbe ??= probeNativeSink();
|
|
88
|
+
return cachedProbe;
|
|
89
|
+
}
|
|
90
|
+
|
|
54
91
|
/** Streaming argv for the known stdin players. */
|
|
55
92
|
export function streamingCommand(
|
|
56
93
|
backend: "ffplay" | "sox",
|
|
@@ -99,8 +136,9 @@ export function streamingCommand(
|
|
|
99
136
|
|
|
100
137
|
/**
|
|
101
138
|
* Pick the audio backend. Order: DAWG_AUDIO=0 (none), DAWG_AUDIO_PLAYER
|
|
102
|
-
* (explicit stdin command), DAWG_AUDIO_BACKEND (forced name), then
|
|
103
|
-
* sox `play`, and afplay (macOS) in that order.
|
|
139
|
+
* (explicit stdin command), DAWG_AUDIO_BACKEND (forced name), then the
|
|
140
|
+
* native sink, ffplay, sox `play`, and afplay (macOS) in that order. When
|
|
141
|
+
* the native sink cannot load, the fallback's detail says why.
|
|
104
142
|
*/
|
|
105
143
|
export function detectAudioBackend(
|
|
106
144
|
options: DetectOptions = {},
|
|
@@ -128,13 +166,40 @@ export function detectAudioBackend(
|
|
|
128
166
|
}
|
|
129
167
|
const forced = env.DAWG_AUDIO_BACKEND?.trim().toLowerCase();
|
|
130
168
|
const candidates: AudioBackend[] =
|
|
169
|
+
forced === "native" ||
|
|
131
170
|
forced === "ffplay" ||
|
|
132
171
|
forced === "sox" ||
|
|
133
172
|
forced === "afplay" ||
|
|
134
173
|
forced === "none"
|
|
135
174
|
? [forced]
|
|
136
|
-
: ["ffplay", "sox", "afplay"];
|
|
175
|
+
: ["native", "ffplay", "sox", "afplay"];
|
|
176
|
+
let unavailable: string | undefined;
|
|
177
|
+
const why = (info: AudioBackendInfo): AudioBackendInfo =>
|
|
178
|
+
unavailable
|
|
179
|
+
? {
|
|
180
|
+
...info,
|
|
181
|
+
detail: `${info.detail} · native sink unavailable: ${unavailable}`,
|
|
182
|
+
nativeUnavailable: unavailable,
|
|
183
|
+
}
|
|
184
|
+
: info;
|
|
137
185
|
for (const candidate of candidates) {
|
|
186
|
+
if (candidate === "native") {
|
|
187
|
+
if (options.native === false) continue;
|
|
188
|
+
const probe = options.native
|
|
189
|
+
? options.native()
|
|
190
|
+
: env === process.env && options.platform === undefined
|
|
191
|
+
? defaultNativeProbe()
|
|
192
|
+
: probeNativeSink({ env, platform });
|
|
193
|
+
if (probe.ok)
|
|
194
|
+
return {
|
|
195
|
+
backend: "native",
|
|
196
|
+
streaming: true,
|
|
197
|
+
native: probe.library,
|
|
198
|
+
detail: `native sink ${probe.detail} (device callback)`,
|
|
199
|
+
};
|
|
200
|
+
unavailable = probe.reason;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
138
203
|
if (candidate === "none")
|
|
139
204
|
return {
|
|
140
205
|
backend: "none",
|
|
@@ -144,26 +209,26 @@ export function detectAudioBackend(
|
|
|
144
209
|
if (candidate === "ffplay" || candidate === "sox") {
|
|
145
210
|
const binary = which(candidate === "ffplay" ? "ffplay" : "play");
|
|
146
211
|
if (!binary) continue;
|
|
147
|
-
return {
|
|
212
|
+
return why({
|
|
148
213
|
backend: candidate,
|
|
149
214
|
streaming: true,
|
|
150
215
|
command: streamingCommand(candidate, binary, sampleRate),
|
|
151
216
|
detail: `${binary} (gapless stream)`,
|
|
152
|
-
};
|
|
217
|
+
});
|
|
153
218
|
}
|
|
154
219
|
const binary = platform === "darwin" ? which("afplay") : null;
|
|
155
220
|
if (binary)
|
|
156
|
-
return {
|
|
221
|
+
return why({
|
|
157
222
|
backend: "afplay",
|
|
158
223
|
streaming: false,
|
|
159
224
|
detail: `${binary} (re-render on edit; install ffmpeg or sox for gapless playback)`,
|
|
160
|
-
};
|
|
225
|
+
});
|
|
161
226
|
}
|
|
162
|
-
return {
|
|
227
|
+
return why({
|
|
163
228
|
backend: "none",
|
|
164
229
|
streaming: false,
|
|
165
230
|
detail: "no player found (install ffmpeg or sox)",
|
|
166
|
-
};
|
|
231
|
+
});
|
|
167
232
|
}
|
|
168
233
|
|
|
169
234
|
/** One status line for `dawg auth status` and the header. */
|
|
@@ -205,8 +270,21 @@ type PlayerProcess = {
|
|
|
205
270
|
readonly stdin: Sink;
|
|
206
271
|
readonly exited: Promise<number>;
|
|
207
272
|
kill(): void;
|
|
273
|
+
/** Device-clocked players (the native sink) pace the pump themselves. */
|
|
274
|
+
readonly clock?: PlayerClock;
|
|
208
275
|
};
|
|
209
276
|
|
|
277
|
+
/** Play-mode lead on the native sink: the ring only covers pump jitter. */
|
|
278
|
+
export const NATIVE_PLAY_LEAD_MS = 15;
|
|
279
|
+
/** Pump interval on the native sink. */
|
|
280
|
+
export const NATIVE_TICK_MS = 5;
|
|
281
|
+
|
|
282
|
+
function envNumber(value: string | undefined): number | undefined {
|
|
283
|
+
if (value === undefined || value.trim() === "") return undefined;
|
|
284
|
+
const number = Number(value);
|
|
285
|
+
return Number.isFinite(number) && number >= 0 ? number : undefined;
|
|
286
|
+
}
|
|
287
|
+
|
|
210
288
|
export type AudioEngineOptions = Readonly<{
|
|
211
289
|
lockPath?: string;
|
|
212
290
|
info?: AudioBackendInfo;
|
|
@@ -228,19 +306,60 @@ export type AudioEngineOptions = Readonly<{
|
|
|
228
306
|
respawnMs?: number;
|
|
229
307
|
/** Player lifecycle notices worth a status line. */
|
|
230
308
|
onStatus?: (status: AudioStatus) => void;
|
|
309
|
+
/** Play-mode lead (default 60 ms; 15 ms on the native sink). */
|
|
310
|
+
playLeadMs?: number;
|
|
311
|
+
/**
|
|
312
|
+
* Output device name (native sink; sox via AUDIODEV). Default:
|
|
313
|
+
* DAWG_AUDIO_DEVICE, then the saved choice (src/audio/devices.ts), then
|
|
314
|
+
* the system default. `null` forces the system default.
|
|
315
|
+
*/
|
|
316
|
+
device?: string | null;
|
|
317
|
+
/**
|
|
318
|
+
* The saved choice to follow (`<config>/audio.json`): checked about once
|
|
319
|
+
* a second while playing and before each player starts, so a choice made
|
|
320
|
+
* in another window or the daemon's client moves playback. Defaults to
|
|
321
|
+
* the real file when the backend is auto-detected (no `info`, no
|
|
322
|
+
* `spawn`, no `device`); `false` never follows one.
|
|
323
|
+
*/
|
|
324
|
+
choicePath?: string | false;
|
|
325
|
+
/** Native sink device buffer frames (default 128; 0 = device default). */
|
|
326
|
+
bufferFrames?: number;
|
|
231
327
|
}>;
|
|
232
328
|
|
|
233
329
|
export type AudioStatus = Readonly<{
|
|
234
|
-
/**
|
|
235
|
-
|
|
330
|
+
/**
|
|
331
|
+
* `restarting`: the player died and is being respawned; `stopped`: gave
|
|
332
|
+
* up; `device`: the chosen output is gone, playback moved to the default.
|
|
333
|
+
*/
|
|
334
|
+
state: "restarting" | "stopped" | "device";
|
|
236
335
|
message: string;
|
|
237
336
|
}>;
|
|
238
337
|
|
|
239
338
|
/** Respawns of a dying player before playback is declared stopped. */
|
|
240
339
|
const MAX_RESPAWNS = 3;
|
|
340
|
+
/** How often a playing engine checks the saved device choice. */
|
|
341
|
+
const CHOICE_CHECK_MS = 1000;
|
|
342
|
+
|
|
343
|
+
function fileMtime(path: string): number {
|
|
344
|
+
try {
|
|
345
|
+
return statSync(path).mtimeMs;
|
|
346
|
+
} catch {
|
|
347
|
+
return 0;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function cleanEnvDevice(): string | undefined {
|
|
352
|
+
return resolveOutputDevice(process.env, {});
|
|
353
|
+
}
|
|
241
354
|
/** A player that ran this long before dying resets the respawn budget. */
|
|
242
355
|
const RESPAWN_RESET_MS = 10_000;
|
|
243
|
-
/**
|
|
356
|
+
/**
|
|
357
|
+
* Default queue lead: the edit-to-audible floor while playing. 100 ms leaves
|
|
358
|
+
* 80 ms of slack over the 20 ms pump tick for main-thread stalls (a frame
|
|
359
|
+
* costs about 1 ms; play mode already runs at 60 ms).
|
|
360
|
+
*/
|
|
361
|
+
const DEFAULT_LEAD_MS = 100;
|
|
362
|
+
/** Inline renders grow the lead to this multiple of the last render time. */
|
|
244
363
|
const LEAD_RENDER_FACTOR = 1.5;
|
|
245
364
|
|
|
246
365
|
type Loop = Readonly<{
|
|
@@ -334,8 +453,8 @@ type PlayRequest = {
|
|
|
334
453
|
*
|
|
335
454
|
* Timing: the engine is paced by the same monotonic clock as the transport.
|
|
336
455
|
* Frame `n` of the stream belongs to wall time `start + n / rate`, and the
|
|
337
|
-
* engine keeps a lead of audio queued ahead of now:
|
|
338
|
-
*
|
|
456
|
+
* engine keeps a lead of audio queued ahead of now: `leadMs`, or 1.5x the
|
|
457
|
+
* last render time when renders run inline (no worker) and are slower. The device
|
|
339
458
|
* plays the queue at its own rate, so what you hear trails the transport by
|
|
340
459
|
* the player's fixed output latency and never accumulates drift from
|
|
341
460
|
* re-renders. Renders run in a worker; an edit re-renders off-thread and
|
|
@@ -351,9 +470,18 @@ export class AudioEngine {
|
|
|
351
470
|
private leadFrames: number;
|
|
352
471
|
private readonly defaultLeadFrames: number;
|
|
353
472
|
private readonly tickMs: number;
|
|
473
|
+
/** The lead play mode and auditions pin (see `setLeadMs`). */
|
|
474
|
+
public readonly playLeadMs: number;
|
|
354
475
|
private readonly now: () => number;
|
|
355
476
|
private readonly useTimer: boolean;
|
|
356
|
-
private readonly
|
|
477
|
+
private readonly customSpawn:
|
|
478
|
+
((command: readonly string[]) => PlayerProcess) | undefined;
|
|
479
|
+
private readonly bufferFrames: number;
|
|
480
|
+
/** The output device the next player opens (undefined: system default). */
|
|
481
|
+
private outputDevice: string | undefined;
|
|
482
|
+
private readonly choicePath: string | false;
|
|
483
|
+
private choiceMtime: number;
|
|
484
|
+
private choiceCheckedMs = Number.NEGATIVE_INFINITY;
|
|
357
485
|
private readonly renderer: LoopRenderer;
|
|
358
486
|
private readonly respawnMs: number;
|
|
359
487
|
private readonly onStatus: ((status: AudioStatus) => void) | undefined;
|
|
@@ -401,17 +529,40 @@ export class AudioEngine {
|
|
|
401
529
|
options.info ?? detectAudioBackend({ sampleRate: this.sampleRate });
|
|
402
530
|
this.lock = new PlaybackLock(options.lockPath);
|
|
403
531
|
this.leadFrames = Math.round(
|
|
404
|
-
((options.leadMs ??
|
|
532
|
+
((options.leadMs ?? DEFAULT_LEAD_MS) * this.sampleRate) / 1000,
|
|
405
533
|
);
|
|
406
534
|
this.defaultLeadFrames = this.leadFrames;
|
|
407
535
|
this.fadeFrames = Math.max(
|
|
408
536
|
1,
|
|
409
537
|
Math.round(SWAP_FADE_SECONDS * this.sampleRate),
|
|
410
538
|
);
|
|
411
|
-
|
|
539
|
+
const native =
|
|
540
|
+
this.info.backend === "native" ? this.info.native : undefined;
|
|
541
|
+
this.tickMs = options.tickMs ?? (native ? NATIVE_TICK_MS : 20);
|
|
542
|
+
this.playLeadMs =
|
|
543
|
+
options.playLeadMs ??
|
|
544
|
+
envNumber(process.env.DAWG_PLAY_LEAD_MS) ??
|
|
545
|
+
(native ? NATIVE_PLAY_LEAD_MS : 60);
|
|
412
546
|
this.now = options.now ?? (() => performance.now());
|
|
413
547
|
this.useTimer = options.timer ?? true;
|
|
414
|
-
this.
|
|
548
|
+
this.choicePath =
|
|
549
|
+
options.choicePath ??
|
|
550
|
+
(options.info === undefined &&
|
|
551
|
+
options.spawn === undefined &&
|
|
552
|
+
options.device === undefined
|
|
553
|
+
? audioChoicePath()
|
|
554
|
+
: false);
|
|
555
|
+
this.outputDevice =
|
|
556
|
+
options.device === null
|
|
557
|
+
? undefined
|
|
558
|
+
: ((options.device?.trim() || undefined) ??
|
|
559
|
+
(this.choicePath
|
|
560
|
+
? resolveOutputDevice(process.env, readAudioChoice(this.choicePath))
|
|
561
|
+
: cleanEnvDevice()));
|
|
562
|
+
this.choiceMtime = this.choicePath ? fileMtime(this.choicePath) : 0;
|
|
563
|
+
this.bufferFrames =
|
|
564
|
+
options.bufferFrames ?? envNumber(process.env.DAWG_AUDIO_BUFFER) ?? 128;
|
|
565
|
+
this.customSpawn = options.spawn;
|
|
415
566
|
this.respawnMs = options.respawnMs ?? 250;
|
|
416
567
|
this.onStatus = options.onStatus;
|
|
417
568
|
this.renderer = new LoopRenderer({
|
|
@@ -452,6 +603,17 @@ export class AudioEngine {
|
|
|
452
603
|
: Math.max(1, Math.round((ms * this.sampleRate) / 1000));
|
|
453
604
|
}
|
|
454
605
|
|
|
606
|
+
/**
|
|
607
|
+
* A few words on how play mode sounds, for the play strip: the native
|
|
608
|
+
* sink, or the stdin player and why the sink is not in use.
|
|
609
|
+
*/
|
|
610
|
+
public get audioNote(): string {
|
|
611
|
+
if (this.info.backend === "native") return "native sink";
|
|
612
|
+
return this.info.nativeUnavailable
|
|
613
|
+
? `${this.info.backend} · no native sink`
|
|
614
|
+
: this.info.backend;
|
|
615
|
+
}
|
|
616
|
+
|
|
455
617
|
/** Lead in milliseconds: how far ahead of now a new live voice sounds. */
|
|
456
618
|
public get leadMs(): number {
|
|
457
619
|
return (this.lead * 1000) / this.sampleRate;
|
|
@@ -619,14 +781,14 @@ export class AudioEngine {
|
|
|
619
781
|
|
|
620
782
|
/** Frames kept queued ahead of the clock right now. */
|
|
621
783
|
public get lead(): number {
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
784
|
+
// Off-thread renders never stall the pump: a slow render delays the
|
|
785
|
+
// swap, not the stream, so only inline renders grow the lead (every
|
|
786
|
+
// frame of lead is a frame of edit latency).
|
|
787
|
+
const adaptive = this.renderer.offThread
|
|
788
|
+
? 0
|
|
789
|
+
: Math.round(
|
|
790
|
+
(LEAD_RENDER_FACTOR * this.lastRenderMs * this.sampleRate) / 1000,
|
|
791
|
+
);
|
|
630
792
|
// The pump never queues more than a second, so the lead stays under it.
|
|
631
793
|
return Math.min(
|
|
632
794
|
Math.max(this.leadFrames, adaptive),
|
|
@@ -719,7 +881,18 @@ export class AudioEngine {
|
|
|
719
881
|
);
|
|
720
882
|
}
|
|
721
883
|
|
|
884
|
+
/**
|
|
885
|
+
* Stream timing on the native sink: the host time of the latest device
|
|
886
|
+
* callback, when a frame is heard, and the device latency. Undefined on
|
|
887
|
+
* the stdin players (their output latency is unknown).
|
|
888
|
+
*/
|
|
889
|
+
public get timing(): AudioTiming | undefined {
|
|
890
|
+
return this.child?.clock?.timing();
|
|
891
|
+
}
|
|
892
|
+
|
|
722
893
|
private queuedFrames(): number {
|
|
894
|
+
const clock = this.child?.clock;
|
|
895
|
+
if (clock) return clock.queuedFrames();
|
|
723
896
|
const elapsed = Math.floor(
|
|
724
897
|
((this.now() - this.startMs) * this.sampleRate) / 1000,
|
|
725
898
|
);
|
|
@@ -781,10 +954,22 @@ export class AudioEngine {
|
|
|
781
954
|
const child = this.child;
|
|
782
955
|
const loop = this.loop;
|
|
783
956
|
if (!child || (!loop && !this.monitoring)) return;
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
957
|
+
if (this.followChoice()) return;
|
|
958
|
+
let remaining: number;
|
|
959
|
+
if (child.clock) {
|
|
960
|
+
// Device-clocked: top the ring up to the lead. Frames the device
|
|
961
|
+
// played as silence (an underrun) are skipped so the loop stays on
|
|
962
|
+
// the transport's beat.
|
|
963
|
+
const slip = child.clock.slipFrames();
|
|
964
|
+
if (slip > 0 && loop) this.cursor = (this.cursor + slip) % loop.frames;
|
|
965
|
+
if (slip > 0) this.endFades();
|
|
966
|
+
remaining = this.lead - child.clock.queuedFrames();
|
|
967
|
+
} else {
|
|
968
|
+
const due =
|
|
969
|
+
Math.floor(((this.now() - this.startMs) * this.sampleRate) / 1000) +
|
|
970
|
+
this.lead;
|
|
971
|
+
remaining = due - this.written;
|
|
972
|
+
}
|
|
788
973
|
if (remaining <= 0) return;
|
|
789
974
|
// Never queue more than one second at once, e.g. after a stalled loop.
|
|
790
975
|
if (remaining > this.sampleRate) {
|
|
@@ -864,6 +1049,8 @@ export class AudioEngine {
|
|
|
864
1049
|
|
|
865
1050
|
/** Wall time (monotonic ms) stream frame `frame` sounds at. */
|
|
866
1051
|
private frameMs(frame: number): number {
|
|
1052
|
+
const clock = this.child?.clock;
|
|
1053
|
+
if (clock) return clock.frameMs(frame);
|
|
867
1054
|
return this.startMs + (frame * 1000) / this.sampleRate;
|
|
868
1055
|
}
|
|
869
1056
|
|
|
@@ -971,6 +1158,134 @@ export class AudioEngine {
|
|
|
971
1158
|
this.loop = next;
|
|
972
1159
|
}
|
|
973
1160
|
|
|
1161
|
+
/** The output device players open; undefined is the system default. */
|
|
1162
|
+
public get device(): string | undefined {
|
|
1163
|
+
return this.outputDevice;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/** Whether this backend can play on a chosen output. */
|
|
1167
|
+
public get choosesDevice(): boolean {
|
|
1168
|
+
return (
|
|
1169
|
+
this.customSpawn === undefined &&
|
|
1170
|
+
(this.info.backend === "native" || this.info.backend === "sox")
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* Move playback to `device` (undefined: the system default). A running
|
|
1176
|
+
* player is replaced at the frame being heard, so the song carries on.
|
|
1177
|
+
*/
|
|
1178
|
+
public setDevice(device: string | undefined): void {
|
|
1179
|
+
const next = device?.trim() || undefined;
|
|
1180
|
+
if (next === this.outputDevice) return;
|
|
1181
|
+
this.outputDevice = next;
|
|
1182
|
+
const child = this.child;
|
|
1183
|
+
if (!child || !this.choosesDevice) return;
|
|
1184
|
+
const loop = this.loop;
|
|
1185
|
+
const heard = this.heardFrame(loop);
|
|
1186
|
+
const at = this.now();
|
|
1187
|
+
const generation = this.generation;
|
|
1188
|
+
this.child = undefined;
|
|
1189
|
+
this.loop = undefined;
|
|
1190
|
+
if (this.timer) clearInterval(this.timer);
|
|
1191
|
+
this.timer = undefined;
|
|
1192
|
+
child.kill();
|
|
1193
|
+
this.starting = child.exited
|
|
1194
|
+
.catch(() => undefined)
|
|
1195
|
+
.then(() => this.lock.release())
|
|
1196
|
+
.then(async () => {
|
|
1197
|
+
if (generation !== this.generation || this.child) return;
|
|
1198
|
+
if (!loop && !this.monitoring) return;
|
|
1199
|
+
const frame = heard + ((this.now() - at) * this.sampleRate) / 1000;
|
|
1200
|
+
await this.start(loop, loop ? beatOfFrame(loop, frame) : 0);
|
|
1201
|
+
})
|
|
1202
|
+
.finally(() => {
|
|
1203
|
+
this.starting = undefined;
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* Follow the saved choice when its file changed (at most once a second).
|
|
1209
|
+
* Returns true when that moved playback to another device.
|
|
1210
|
+
*/
|
|
1211
|
+
private followChoice(force = false): boolean {
|
|
1212
|
+
if (!this.choicePath) return false;
|
|
1213
|
+
const now = this.now();
|
|
1214
|
+
if (!force && now - this.choiceCheckedMs < CHOICE_CHECK_MS) return false;
|
|
1215
|
+
this.choiceCheckedMs = now;
|
|
1216
|
+
const mtime = fileMtime(this.choicePath);
|
|
1217
|
+
if (mtime === this.choiceMtime) return false;
|
|
1218
|
+
this.choiceMtime = mtime;
|
|
1219
|
+
const next = resolveOutputDevice(
|
|
1220
|
+
process.env,
|
|
1221
|
+
readAudioChoice(this.choicePath),
|
|
1222
|
+
);
|
|
1223
|
+
if (next === this.outputDevice) return false;
|
|
1224
|
+
this.setDevice(next);
|
|
1225
|
+
return true;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
private heardFrame(loop: Loop | undefined): number {
|
|
1229
|
+
return loop
|
|
1230
|
+
? (((this.cursor - this.queuedFrames()) % loop.frames) + loop.frames) %
|
|
1231
|
+
loop.frames
|
|
1232
|
+
: 0;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/** Open a player on the chosen output, or the default when it is gone. */
|
|
1236
|
+
private spawnPlayer(command: readonly string[]): PlayerProcess {
|
|
1237
|
+
if (this.customSpawn) return this.customSpawn(command);
|
|
1238
|
+
this.followChoice(true);
|
|
1239
|
+
const native =
|
|
1240
|
+
this.info.backend === "native" ? this.info.native : undefined;
|
|
1241
|
+
const device = this.outputDevice;
|
|
1242
|
+
if (native) {
|
|
1243
|
+
const open = (name: string | undefined) =>
|
|
1244
|
+
openNativePlayer(native, {
|
|
1245
|
+
rate: this.sampleRate,
|
|
1246
|
+
channels: RENDER_CHANNELS,
|
|
1247
|
+
...(name ? { device: name } : {}),
|
|
1248
|
+
bufferFrames: this.bufferFrames,
|
|
1249
|
+
now: this.now,
|
|
1250
|
+
});
|
|
1251
|
+
if (!device) return open(undefined);
|
|
1252
|
+
try {
|
|
1253
|
+
return open(device);
|
|
1254
|
+
} catch (error) {
|
|
1255
|
+
const player = open(undefined);
|
|
1256
|
+
this.deviceLost(device, error);
|
|
1257
|
+
return player;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
return spawnStdinPlayer(
|
|
1261
|
+
command,
|
|
1262
|
+
this.info.backend === "sox" && device ? { AUDIODEV: device } : undefined,
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/** The chosen output is gone: play on the default and say so once. */
|
|
1267
|
+
private deviceLost(device: string, _error?: unknown): void {
|
|
1268
|
+
if (this.outputDevice !== device) return;
|
|
1269
|
+
this.outputDevice = undefined;
|
|
1270
|
+
this.onStatus?.({
|
|
1271
|
+
state: "device",
|
|
1272
|
+
message: `audio output "${device}" is unavailable · playing on the system default`,
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/** After a player died: is its chosen output still there? */
|
|
1277
|
+
private deviceGone(): string | undefined {
|
|
1278
|
+
const device = this.outputDevice;
|
|
1279
|
+
if (!device || !this.choosesDevice) return undefined;
|
|
1280
|
+
if (this.info.backend === "sox") return device;
|
|
1281
|
+
try {
|
|
1282
|
+
const names = this.info.native?.devices(false) ?? [];
|
|
1283
|
+
return names.some((entry) => entry.name === device) ? undefined : device;
|
|
1284
|
+
} catch {
|
|
1285
|
+
return undefined;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
974
1289
|
private async start(loop: Loop | undefined, beat: number): Promise<void> {
|
|
975
1290
|
const generation = this.generation;
|
|
976
1291
|
if (!(await this.lock.acquire())) return;
|
|
@@ -1010,11 +1325,9 @@ export class AudioEngine {
|
|
|
1010
1325
|
const loop = this.loop;
|
|
1011
1326
|
const ranMs = this.now() - this.startMs;
|
|
1012
1327
|
// Frame the device was playing when the stream ended.
|
|
1013
|
-
const heard = loop
|
|
1014
|
-
? (((this.cursor - this.queuedFrames()) % loop.frames) + loop.frames) %
|
|
1015
|
-
loop.frames
|
|
1016
|
-
: 0;
|
|
1328
|
+
const heard = this.heardFrame(loop);
|
|
1017
1329
|
const diedAt = this.now();
|
|
1330
|
+
const lost = this.deviceGone();
|
|
1018
1331
|
this.child = undefined;
|
|
1019
1332
|
this.loop = undefined;
|
|
1020
1333
|
if (this.timer) clearInterval(this.timer);
|
|
@@ -1031,10 +1344,13 @@ export class AudioEngine {
|
|
|
1031
1344
|
}
|
|
1032
1345
|
this.respawns += 1;
|
|
1033
1346
|
const delay = this.respawnMs * 2 ** (this.respawns - 1);
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1347
|
+
// An unplugged output: one notice, then the default device.
|
|
1348
|
+
if (lost) this.deviceLost(lost);
|
|
1349
|
+
else
|
|
1350
|
+
this.onStatus?.({
|
|
1351
|
+
state: "restarting",
|
|
1352
|
+
message: `audio player exited; restarting (${this.respawns}/${MAX_RESPAWNS})`,
|
|
1353
|
+
});
|
|
1038
1354
|
this.respawnTimer = setTimeout(() => {
|
|
1039
1355
|
this.respawnTimer = undefined;
|
|
1040
1356
|
if (generation !== this.generation || this.child || this.starting)
|
|
@@ -1049,9 +1365,13 @@ export class AudioEngine {
|
|
|
1049
1365
|
}
|
|
1050
1366
|
}
|
|
1051
1367
|
|
|
1052
|
-
function spawnStdinPlayer(
|
|
1368
|
+
function spawnStdinPlayer(
|
|
1369
|
+
command: readonly string[],
|
|
1370
|
+
env?: Readonly<Record<string, string>>,
|
|
1371
|
+
): PlayerProcess {
|
|
1053
1372
|
if (command.length === 0) throw new Error("no player command");
|
|
1054
1373
|
const child = Bun.spawn([...command], {
|
|
1374
|
+
...(env ? { env: { ...process.env, ...env } } : {}),
|
|
1055
1375
|
stdin: "pipe",
|
|
1056
1376
|
stdout: "ignore",
|
|
1057
1377
|
stderr: "ignore",
|
package/src/audio/fit.ts
CHANGED
|
@@ -53,7 +53,12 @@ const onsetCache = new Map<string, readonly number[]>();
|
|
|
53
53
|
/** Background jobs by cache key, with the window they fit (one per window). */
|
|
54
54
|
const pending = new Map<
|
|
55
55
|
string,
|
|
56
|
-
{
|
|
56
|
+
{
|
|
57
|
+
job: Generator<void, Float32Array>;
|
|
58
|
+
window: string;
|
|
59
|
+
/** Another stage's cache (0.7 autotune) takes the result instead. */
|
|
60
|
+
done?: (out: Float32Array) => void;
|
|
61
|
+
}
|
|
57
62
|
>();
|
|
58
63
|
let live = false;
|
|
59
64
|
/** True when the current live render left a voice silent while fitting. */
|
|
@@ -191,6 +196,32 @@ export function shiftedBuffer(
|
|
|
191
196
|
return out;
|
|
192
197
|
}
|
|
193
198
|
|
|
199
|
+
/** True inside `withLiveFit` (0.7: other stages defer long work too). */
|
|
200
|
+
export function liveFitActive(): boolean {
|
|
201
|
+
return live;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Runs `job` between live blocks like a long fit and hands the result to
|
|
206
|
+
* `done` (the caller's own cache); `onFitReady` fires when it lands and
|
|
207
|
+
* the live render reports `fitting` until then (0.7 autotune).
|
|
208
|
+
*/
|
|
209
|
+
export function deferLiveJob<T>(
|
|
210
|
+
key: string,
|
|
211
|
+
job: () => Generator<void, T>,
|
|
212
|
+
done: (out: T) => void,
|
|
213
|
+
): void {
|
|
214
|
+
missed = true;
|
|
215
|
+
if (pending.has(key)) return;
|
|
216
|
+
// The scheduler only steps the job and hands its value to `done`.
|
|
217
|
+
pending.set(key, {
|
|
218
|
+
job: job() as unknown as Generator<void, Float32Array>,
|
|
219
|
+
window: key,
|
|
220
|
+
done: done as unknown as (out: Float32Array) => void,
|
|
221
|
+
});
|
|
222
|
+
schedule();
|
|
223
|
+
}
|
|
224
|
+
|
|
194
225
|
function drain(job: Generator<void, Float32Array>): Float32Array {
|
|
195
226
|
for (;;) {
|
|
196
227
|
const step = job.next();
|
|
@@ -204,13 +235,14 @@ function schedule(): void {
|
|
|
204
235
|
timer = undefined;
|
|
205
236
|
const next = pending.entries().next();
|
|
206
237
|
if (next.done) return;
|
|
207
|
-
const [key, { job }] = next.value;
|
|
238
|
+
const [key, { job, done }] = next.value;
|
|
208
239
|
const until = performance.now() + 8;
|
|
209
240
|
for (;;) {
|
|
210
241
|
const step = job.next();
|
|
211
242
|
if (step.done) {
|
|
212
243
|
pending.delete(key);
|
|
213
|
-
|
|
244
|
+
if (done) done(step.value);
|
|
245
|
+
else remember(key, step.value);
|
|
214
246
|
for (const listener of listeners) listener();
|
|
215
247
|
break;
|
|
216
248
|
}
|