@hraness/dawg 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -0
- package/DAWG.md +196 -11
- package/README.md +79 -30
- package/core/diff.ts +249 -0
- package/core/key.ts +43 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +324 -1
- 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 +8 -5
- package/src/agent/agent.ts +280 -14
- package/src/agent/brief.ts +23 -3
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/planner.ts +38 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +398 -1
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +27 -12
- package/src/agent/xcb.ts +233 -21
- package/src/audio/click.ts +125 -0
- package/src/audio/engine.ts +407 -33
- package/src/audio/live.ts +152 -0
- package/src/audio/player.ts +19 -4
- 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 +290 -76
- 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/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +24 -7
- package/src/commands/sample.ts +302 -0
- package/src/main.ts +1043 -166
- 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 +28 -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 +3 -37
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- 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 +42 -3
- package/tui/app.ts +261 -18
- package/tui/drums.ts +44 -0
- package/tui/highway.ts +18 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +143 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live voices for play mode: one note of one track, rendered through the
|
|
3
|
+
* same offline renderer the loop uses (`renderScorePcm`), so a key sounds
|
|
4
|
+
* exactly like the note it records as: the track's instrument voice (synth,
|
|
5
|
+
* drum kit or sampler), filter, pan, delay and reverb. Nothing is forked;
|
|
6
|
+
* this module only builds a one-note score and caches the result.
|
|
7
|
+
*
|
|
8
|
+
* The offline renderer is untouched, so loop renders and exports stay
|
|
9
|
+
* byte-identical. Live audio never reaches a render or an export: the engine
|
|
10
|
+
* mixes it into the stream only.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
SCORE_LIMITS,
|
|
14
|
+
TrackScore,
|
|
15
|
+
isSamplerInstrument,
|
|
16
|
+
type Track,
|
|
17
|
+
} from "../../core/score.ts";
|
|
18
|
+
import { sampleKey, type SampleBank } from "./samples.ts";
|
|
19
|
+
import { RENDER_CHANNELS, renderScorePcm } from "./wav.ts";
|
|
20
|
+
|
|
21
|
+
/** A rendered live note: interleaved stereo 16-bit PCM. */
|
|
22
|
+
export type LiveNotePcm = Readonly<{ pcm: Int16Array; frames: number }>;
|
|
23
|
+
|
|
24
|
+
export type LiveNoteRequest = Readonly<{
|
|
25
|
+
score: TrackScore;
|
|
26
|
+
trackId: string;
|
|
27
|
+
pitch: number;
|
|
28
|
+
/** 0..1, as score notes store it. */
|
|
29
|
+
velocity: number;
|
|
30
|
+
/** How long the key is held. */
|
|
31
|
+
seconds: number;
|
|
32
|
+
/** Decoded sampler voices; without it sampler tracks are silent. */
|
|
33
|
+
samples?: SampleBank;
|
|
34
|
+
}>;
|
|
35
|
+
|
|
36
|
+
/** Longest a single live note is rendered for (a held sustain pedal). */
|
|
37
|
+
export const MAX_LIVE_NOTE_SECONDS = 8;
|
|
38
|
+
const CACHE_ENTRIES = 96;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Renders and caches live notes. Repeated keys hit the cache, so the key
|
|
42
|
+
* handler only pays for a render the first time a pitch/length is played.
|
|
43
|
+
*/
|
|
44
|
+
export class LiveSynth {
|
|
45
|
+
private readonly cache = new Map<string, LiveNotePcm>();
|
|
46
|
+
|
|
47
|
+
public constructor(private readonly sampleRate: number) {}
|
|
48
|
+
|
|
49
|
+
public get rate(): number {
|
|
50
|
+
return this.sampleRate;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public get cached(): number {
|
|
54
|
+
return this.cache.size;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public render(request: LiveNoteRequest): LiveNotePcm | undefined {
|
|
58
|
+
const track = request.score.tracks.find(
|
|
59
|
+
(candidate) => candidate.id === request.trackId,
|
|
60
|
+
);
|
|
61
|
+
if (!track) return undefined;
|
|
62
|
+
const { score } = request;
|
|
63
|
+
const ticksPerSecond = (score.ticksPerBeat * score.tempoBpm) / 60;
|
|
64
|
+
const seconds = Math.max(
|
|
65
|
+
0.01,
|
|
66
|
+
Math.min(MAX_LIVE_NOTE_SECONDS, request.seconds),
|
|
67
|
+
);
|
|
68
|
+
const durationTicks = Math.max(
|
|
69
|
+
1,
|
|
70
|
+
Math.min(SCORE_LIMITS.maxTick, Math.round(seconds * ticksPerSecond)),
|
|
71
|
+
);
|
|
72
|
+
const velocity = Math.max(0, Math.min(1, request.velocity));
|
|
73
|
+
const pitch = Math.max(0, Math.min(127, Math.round(request.pitch)));
|
|
74
|
+
const key = JSON.stringify([
|
|
75
|
+
liveTrack(track),
|
|
76
|
+
pitch,
|
|
77
|
+
velocity,
|
|
78
|
+
durationTicks,
|
|
79
|
+
score.tempoBpm,
|
|
80
|
+
score.ticksPerBeat,
|
|
81
|
+
this.sampleRate,
|
|
82
|
+
samplerDigest(track, request.samples),
|
|
83
|
+
]);
|
|
84
|
+
const hit = this.cache.get(key);
|
|
85
|
+
if (hit) {
|
|
86
|
+
// Refresh LRU order.
|
|
87
|
+
this.cache.delete(key);
|
|
88
|
+
this.cache.set(key, hit);
|
|
89
|
+
return hit;
|
|
90
|
+
}
|
|
91
|
+
const beats = durationTicks / score.ticksPerBeat;
|
|
92
|
+
const bars = Math.max(
|
|
93
|
+
1,
|
|
94
|
+
Math.min(SCORE_LIMITS.maxBars, Math.ceil(beats / score.beatsPerBar)),
|
|
95
|
+
);
|
|
96
|
+
const single = new TrackScore({
|
|
97
|
+
tempoBpm: score.tempoBpm,
|
|
98
|
+
beatsPerBar: score.beatsPerBar,
|
|
99
|
+
bars,
|
|
100
|
+
ticksPerBeat: score.ticksPerBeat,
|
|
101
|
+
tracks: [liveTrack(track)],
|
|
102
|
+
notes: [
|
|
103
|
+
{
|
|
104
|
+
id: "live",
|
|
105
|
+
trackId: track.id,
|
|
106
|
+
startTick: 0,
|
|
107
|
+
durationTicks,
|
|
108
|
+
pitch,
|
|
109
|
+
velocity,
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
});
|
|
113
|
+
const audio = renderScorePcm(single, {
|
|
114
|
+
sampleRate: this.sampleRate,
|
|
115
|
+
maxSeconds: MAX_LIVE_NOTE_SECONDS + 4,
|
|
116
|
+
...(request.samples ? { samples: request.samples } : {}),
|
|
117
|
+
});
|
|
118
|
+
const frames = audibleFrames(audio.pcm);
|
|
119
|
+
const rendered: LiveNotePcm = {
|
|
120
|
+
pcm: audio.pcm.subarray(0, frames * RENDER_CHANNELS),
|
|
121
|
+
frames,
|
|
122
|
+
};
|
|
123
|
+
this.cache.set(key, rendered);
|
|
124
|
+
while (this.cache.size > CACHE_ENTRIES) {
|
|
125
|
+
const oldest = this.cache.keys().next().value;
|
|
126
|
+
if (oldest === undefined) break;
|
|
127
|
+
this.cache.delete(oldest);
|
|
128
|
+
}
|
|
129
|
+
return rendered;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The track as it sounds live: audible regardless of mute and solo. */
|
|
134
|
+
function liveTrack(track: Track): Track {
|
|
135
|
+
const { solo: _solo, ...rest } = track;
|
|
136
|
+
return { ...rest, muted: false };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function samplerDigest(track: Track, bank: SampleBank | undefined): string[] {
|
|
140
|
+
if (!isSamplerInstrument(track.instrument) || !track.sampler || !bank)
|
|
141
|
+
return [];
|
|
142
|
+
return Object.keys(track.sampler.voices)
|
|
143
|
+
.sort()
|
|
144
|
+
.map((voice) => bank.voices.get(sampleKey(track.id, voice))?.sha256 ?? "-");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Frames up to the last non-silent sample, so mixing skips the dead tail. */
|
|
148
|
+
function audibleFrames(pcm: Int16Array): number {
|
|
149
|
+
let last = pcm.length - 1;
|
|
150
|
+
while (last >= 0 && pcm[last] === 0) last -= 1;
|
|
151
|
+
return Math.ceil((last + 1) / RENDER_CHANNELS);
|
|
152
|
+
}
|
package/src/audio/player.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { unlink } from "node:fs/promises";
|
|
2
2
|
import type { TrackScore } from "../../core/score.ts";
|
|
3
3
|
import { PlaybackLock } from "./lock.ts";
|
|
4
|
+
import { SampleLibrary, hasSamplerTracks, type SampleBank } from "./samples.ts";
|
|
4
5
|
import { RENDER_CHANNELS, encodeWav, renderScorePcm } from "./wav.ts";
|
|
5
6
|
|
|
6
7
|
/** Small best-effort local player. The score and transport remain testable without a sound device. */
|
|
@@ -10,7 +11,12 @@ export class LoopPlayer {
|
|
|
10
11
|
private readonly lock: PlaybackLock;
|
|
11
12
|
private readonly lockPath: string | undefined;
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
private library: SampleLibrary | undefined;
|
|
15
|
+
|
|
16
|
+
public constructor(
|
|
17
|
+
lockPath?: string,
|
|
18
|
+
private readonly projectRoot?: string,
|
|
19
|
+
) {
|
|
14
20
|
this.lockPath = lockPath;
|
|
15
21
|
this.lock = new PlaybackLock(lockPath);
|
|
16
22
|
}
|
|
@@ -33,8 +39,13 @@ export class LoopPlayer {
|
|
|
33
39
|
await this.stopAsync();
|
|
34
40
|
if (process.env.DAWG_AUDIO === "0") return;
|
|
35
41
|
if (this.lockPath && !(await this.acquireLock())) return;
|
|
42
|
+
let samples: SampleBank | undefined;
|
|
43
|
+
if (this.projectRoot !== undefined && hasSamplerTracks(score)) {
|
|
44
|
+
this.library ??= new SampleLibrary({ projectRoot: this.projectRoot });
|
|
45
|
+
samples = await this.library.load(score).catch(() => undefined);
|
|
46
|
+
}
|
|
36
47
|
const path = `/tmp/dawg-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.wav`;
|
|
37
|
-
await Bun.write(path, renderLoopWav(score, beat));
|
|
48
|
+
await Bun.write(path, renderLoopWav(score, beat, samples));
|
|
38
49
|
this.file = path;
|
|
39
50
|
this.spawn(path);
|
|
40
51
|
}
|
|
@@ -102,8 +113,12 @@ export class LoopPlayer {
|
|
|
102
113
|
}
|
|
103
114
|
|
|
104
115
|
/** A loop-folded WAV whose first frame is `beat` into the loop. */
|
|
105
|
-
export function renderLoopWav(
|
|
106
|
-
|
|
116
|
+
export function renderLoopWav(
|
|
117
|
+
score: TrackScore,
|
|
118
|
+
beat = 0,
|
|
119
|
+
samples?: SampleBank,
|
|
120
|
+
): Uint8Array {
|
|
121
|
+
const audio = renderScorePcm(score, { loop: true, samples });
|
|
107
122
|
const framesPerBeat = (60 * audio.sampleRate) / score.tempoBpm;
|
|
108
123
|
const start =
|
|
109
124
|
((Math.round(Math.max(0, beat) * framesPerBeat) % audio.frames) +
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Off-thread loop renderer for the audio engine. One worker per engine
|
|
3
|
+
* keeps a stem cache across renders, so an edit re-renders only the tracks
|
|
4
|
+
* it touched while the daemon loop keeps serving windows and pumping audio.
|
|
5
|
+
*/
|
|
6
|
+
import { scoreFromJSON } from "../../core/score.ts";
|
|
7
|
+
import { SampleLibrary, hasSamplerTracks } from "./samples.ts";
|
|
8
|
+
import { StemRenderer } from "./wav.ts";
|
|
9
|
+
|
|
10
|
+
export type RenderRequest = Readonly<{
|
|
11
|
+
id: number;
|
|
12
|
+
score: unknown;
|
|
13
|
+
sampleRate: number;
|
|
14
|
+
/** Project root sampler voices resolve under; omitted = samplers silent. */
|
|
15
|
+
projectRoot?: string;
|
|
16
|
+
}>;
|
|
17
|
+
|
|
18
|
+
export type RenderReply =
|
|
19
|
+
| Readonly<{
|
|
20
|
+
id: number;
|
|
21
|
+
ok: true;
|
|
22
|
+
frames: number;
|
|
23
|
+
sampleRate: number;
|
|
24
|
+
pcm: Int16Array;
|
|
25
|
+
/** Milliseconds spent rendering, excluding transfer. */
|
|
26
|
+
renderMs: number;
|
|
27
|
+
}>
|
|
28
|
+
| Readonly<{ id: number; ok: false; error: string }>;
|
|
29
|
+
|
|
30
|
+
declare const self: Worker;
|
|
31
|
+
|
|
32
|
+
const renderer = new StemRenderer();
|
|
33
|
+
let library: SampleLibrary | undefined;
|
|
34
|
+
|
|
35
|
+
self.onmessage = async (event: MessageEvent<RenderRequest>) => {
|
|
36
|
+
const { id, score, sampleRate, projectRoot } = event.data;
|
|
37
|
+
try {
|
|
38
|
+
const started = performance.now();
|
|
39
|
+
const parsed = scoreFromJSON(score);
|
|
40
|
+
let samples;
|
|
41
|
+
if (projectRoot !== undefined && hasSamplerTracks(parsed)) {
|
|
42
|
+
if (library?.projectRoot !== projectRoot)
|
|
43
|
+
library = new SampleLibrary({ projectRoot });
|
|
44
|
+
samples = await library.load(parsed);
|
|
45
|
+
}
|
|
46
|
+
const audio = renderer.render(parsed, {
|
|
47
|
+
sampleRate,
|
|
48
|
+
loop: true,
|
|
49
|
+
samples,
|
|
50
|
+
});
|
|
51
|
+
const reply: RenderReply = {
|
|
52
|
+
id,
|
|
53
|
+
ok: true,
|
|
54
|
+
frames: audio.frames,
|
|
55
|
+
sampleRate: audio.sampleRate,
|
|
56
|
+
pcm: audio.pcm,
|
|
57
|
+
renderMs: performance.now() - started,
|
|
58
|
+
};
|
|
59
|
+
self.postMessage(reply, [audio.pcm.buffer]);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const reply: RenderReply = {
|
|
62
|
+
id,
|
|
63
|
+
ok: false,
|
|
64
|
+
error: error instanceof Error ? error.message : String(error),
|
|
65
|
+
};
|
|
66
|
+
self.postMessage(reply);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { TrackScore } from "../../core/score.ts";
|
|
2
|
+
import type { RenderReply, RenderRequest } from "./render-worker.ts";
|
|
3
|
+
import { SampleLibrary, hasSamplerTracks } from "./samples.ts";
|
|
4
|
+
import { StemRenderer } from "./wav.ts";
|
|
5
|
+
|
|
6
|
+
export type LoopRender = Readonly<{
|
|
7
|
+
pcm: Int16Array;
|
|
8
|
+
frames: number;
|
|
9
|
+
sampleRate: number;
|
|
10
|
+
/** Milliseconds the render itself took (worker or inline). */
|
|
11
|
+
renderMs: number;
|
|
12
|
+
}>;
|
|
13
|
+
|
|
14
|
+
export type LoopRendererOptions = Readonly<{
|
|
15
|
+
sampleRate: number;
|
|
16
|
+
/** Set false to render on the calling thread (tests, diagnostics). */
|
|
17
|
+
worker?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Project root sampler voices resolve under (decoded in the worker, or
|
|
20
|
+
* inline on fallback). Without it sampler tracks render silent.
|
|
21
|
+
*/
|
|
22
|
+
projectRoot?: string;
|
|
23
|
+
}>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Renders loops for the engine off the main thread. A single long-lived
|
|
27
|
+
* worker owns a stem cache; the PCM buffer comes back transferred, not
|
|
28
|
+
* copied. If the worker cannot start or crashes, rendering falls back to the
|
|
29
|
+
* calling thread with its own cache, so sound never depends on the worker.
|
|
30
|
+
*/
|
|
31
|
+
export class LoopRenderer {
|
|
32
|
+
private readonly sampleRate: number;
|
|
33
|
+
private readonly useWorker: boolean;
|
|
34
|
+
private worker: Bun.Worker | undefined;
|
|
35
|
+
private workerBroken = false;
|
|
36
|
+
private inline: StemRenderer | undefined;
|
|
37
|
+
private library: SampleLibrary | undefined;
|
|
38
|
+
private readonly projectRoot: string | undefined;
|
|
39
|
+
private nextId = 1;
|
|
40
|
+
private readonly pending = new Map<
|
|
41
|
+
number,
|
|
42
|
+
{ resolve: (render: LoopRender) => void; reject: (error: Error) => void }
|
|
43
|
+
>();
|
|
44
|
+
private disposed = false;
|
|
45
|
+
|
|
46
|
+
public constructor(options: LoopRendererOptions) {
|
|
47
|
+
this.sampleRate = options.sampleRate;
|
|
48
|
+
this.useWorker = options.worker ?? true;
|
|
49
|
+
this.projectRoot = options.projectRoot;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** True while renders run in the worker. */
|
|
53
|
+
public get offThread(): boolean {
|
|
54
|
+
return this.useWorker && !this.workerBroken;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public async render(score: TrackScore): Promise<LoopRender> {
|
|
58
|
+
if (this.disposed) throw new Error("renderer disposed");
|
|
59
|
+
if (this.offThread) {
|
|
60
|
+
try {
|
|
61
|
+
return await this.renderInWorker(score);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (!(error instanceof WorkerLostError)) throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return this.renderInline(score);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Stops the worker; in-flight renders fall back inline on their callers. */
|
|
70
|
+
public dispose(): void {
|
|
71
|
+
this.disposed = true;
|
|
72
|
+
this.failWorker(new WorkerLostError("renderer disposed"));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async renderInline(score: TrackScore): Promise<LoopRender> {
|
|
76
|
+
this.inline ??= new StemRenderer();
|
|
77
|
+
const started = performance.now();
|
|
78
|
+
let samples;
|
|
79
|
+
if (this.projectRoot !== undefined && hasSamplerTracks(score)) {
|
|
80
|
+
this.library ??= new SampleLibrary({ projectRoot: this.projectRoot });
|
|
81
|
+
samples = await this.library.load(score);
|
|
82
|
+
}
|
|
83
|
+
const audio = this.inline.render(score, {
|
|
84
|
+
sampleRate: this.sampleRate,
|
|
85
|
+
loop: true,
|
|
86
|
+
samples,
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
pcm: audio.pcm,
|
|
90
|
+
frames: audio.frames,
|
|
91
|
+
sampleRate: audio.sampleRate,
|
|
92
|
+
renderMs: performance.now() - started,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private renderInWorker(score: TrackScore): Promise<LoopRender> {
|
|
97
|
+
const worker = this.ensureWorker();
|
|
98
|
+
const id = this.nextId;
|
|
99
|
+
this.nextId += 1;
|
|
100
|
+
return new Promise<LoopRender>((resolve, reject) => {
|
|
101
|
+
this.pending.set(id, { resolve, reject });
|
|
102
|
+
const request: RenderRequest = {
|
|
103
|
+
id,
|
|
104
|
+
score: score.toJSON(),
|
|
105
|
+
sampleRate: this.sampleRate,
|
|
106
|
+
...(this.projectRoot === undefined
|
|
107
|
+
? {}
|
|
108
|
+
: { projectRoot: this.projectRoot }),
|
|
109
|
+
};
|
|
110
|
+
try {
|
|
111
|
+
worker.postMessage(request);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
this.failWorker(
|
|
114
|
+
new WorkerLostError(
|
|
115
|
+
error instanceof Error ? error.message : String(error),
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private ensureWorker(): Bun.Worker {
|
|
123
|
+
if (this.worker) return this.worker;
|
|
124
|
+
let worker: Bun.Worker;
|
|
125
|
+
try {
|
|
126
|
+
// The DOM lib's Worker type lacks Bun's `unref`; the runtime object is Bun's.
|
|
127
|
+
worker = new Worker(
|
|
128
|
+
new URL("./render-worker.ts", import.meta.url).href,
|
|
129
|
+
) as unknown as Bun.Worker;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
throw new WorkerLostError(
|
|
132
|
+
error instanceof Error ? error.message : String(error),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
worker.addEventListener("message", (event: Event) => {
|
|
136
|
+
const reply = (event as MessageEvent<RenderReply>).data;
|
|
137
|
+
const waiter = this.pending.get(reply.id);
|
|
138
|
+
if (!waiter) return;
|
|
139
|
+
this.pending.delete(reply.id);
|
|
140
|
+
if (reply.ok)
|
|
141
|
+
waiter.resolve({
|
|
142
|
+
pcm: reply.pcm,
|
|
143
|
+
frames: reply.frames,
|
|
144
|
+
sampleRate: reply.sampleRate,
|
|
145
|
+
renderMs: reply.renderMs,
|
|
146
|
+
});
|
|
147
|
+
else waiter.reject(new Error(reply.error));
|
|
148
|
+
});
|
|
149
|
+
worker.addEventListener("error", (event: Event) => {
|
|
150
|
+
const message = (event as Partial<ErrorEvent>).message;
|
|
151
|
+
this.failWorker(
|
|
152
|
+
new WorkerLostError(
|
|
153
|
+
typeof message === "string" ? message : "worker error",
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
// A render worker never keeps the process alive on its own.
|
|
158
|
+
worker.unref();
|
|
159
|
+
this.worker = worker;
|
|
160
|
+
return worker;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private failWorker(error: WorkerLostError): void {
|
|
164
|
+
this.workerBroken = true;
|
|
165
|
+
const worker = this.worker;
|
|
166
|
+
this.worker = undefined;
|
|
167
|
+
worker?.terminate();
|
|
168
|
+
const waiters = [...this.pending.values()];
|
|
169
|
+
this.pending.clear();
|
|
170
|
+
for (const waiter of waiters) waiter.reject(error);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
class WorkerLostError extends Error {}
|