@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,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
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { mkdir, rm, stat, utimes } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
/** Locks older than this without a heartbeat belong to a crashed player. */
|
|
4
|
+
const STALE_LOCK_MS = 15_000;
|
|
5
|
+
const HEARTBEAT_MS = 5_000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The per-session audio lock: one directory that at most one process (a
|
|
9
|
+
* window in file mode, or dawgd) holds while it is making sound. Its mtime
|
|
10
|
+
* is a heartbeat so a crashed owner is reclaimed after STALE_LOCK_MS.
|
|
11
|
+
*/
|
|
12
|
+
export class PlaybackLock {
|
|
13
|
+
private owned = false;
|
|
14
|
+
private heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
15
|
+
|
|
16
|
+
public constructor(private readonly path: string | undefined) {}
|
|
17
|
+
|
|
18
|
+
public get held(): boolean {
|
|
19
|
+
return this.path === undefined || this.owned;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public async acquire(): Promise<boolean> {
|
|
23
|
+
if (!this.path) return true;
|
|
24
|
+
if (this.owned) return true;
|
|
25
|
+
try {
|
|
26
|
+
await mkdir(this.path);
|
|
27
|
+
await Bun.write(`${this.path}/owner`, `${process.pid}\n`);
|
|
28
|
+
this.owned = true;
|
|
29
|
+
this.heartbeat = setInterval(() => {
|
|
30
|
+
if (!this.path || !this.owned) return;
|
|
31
|
+
void utimes(this.path, new Date(), new Date()).catch(() => undefined);
|
|
32
|
+
}, HEARTBEAT_MS);
|
|
33
|
+
return true;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if ((error as { code?: string }).code !== "EEXIST") throw error;
|
|
36
|
+
try {
|
|
37
|
+
if (Date.now() - (await stat(this.path)).mtimeMs > STALE_LOCK_MS) {
|
|
38
|
+
await rm(this.path, { recursive: true, force: true });
|
|
39
|
+
return this.acquire();
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
/* another window released between stat and retry */
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public async release(): Promise<void> {
|
|
49
|
+
if (!this.owned || !this.path) return;
|
|
50
|
+
this.owned = false;
|
|
51
|
+
if (this.heartbeat) {
|
|
52
|
+
clearInterval(this.heartbeat);
|
|
53
|
+
this.heartbeat = undefined;
|
|
54
|
+
}
|
|
55
|
+
await rm(this.path, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { unlink } from "node:fs/promises";
|
|
2
|
+
import type { TrackScore } from "../../core/score.ts";
|
|
3
|
+
import { PlaybackLock } from "./lock.ts";
|
|
4
|
+
import { SampleLibrary, hasSamplerTracks, type SampleBank } from "./samples.ts";
|
|
5
|
+
import { RENDER_CHANNELS, encodeWav, renderScorePcm } from "./wav.ts";
|
|
6
|
+
|
|
7
|
+
/** Small best-effort local player. The score and transport remain testable without a sound device. */
|
|
8
|
+
export class LoopPlayer {
|
|
9
|
+
private process: { kill: () => void; exited?: Promise<number> } | undefined;
|
|
10
|
+
private file: string | undefined;
|
|
11
|
+
private readonly lock: PlaybackLock;
|
|
12
|
+
private readonly lockPath: string | undefined;
|
|
13
|
+
|
|
14
|
+
private library: SampleLibrary | undefined;
|
|
15
|
+
|
|
16
|
+
public constructor(
|
|
17
|
+
lockPath?: string,
|
|
18
|
+
private readonly projectRoot?: string,
|
|
19
|
+
) {
|
|
20
|
+
this.lockPath = lockPath;
|
|
21
|
+
this.lock = new PlaybackLock(lockPath);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private get ownsLock(): boolean {
|
|
25
|
+
return this.lockPath !== undefined && this.lock.held;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether this process currently owns the session's shared audio lock. */
|
|
29
|
+
public get ownsPlaybackLock(): boolean {
|
|
30
|
+
return this.ownsLock;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Render one seamless loop (tails folded onto the start), rotated so the
|
|
35
|
+
* file begins at `beat`, then (re)start the one-shot player on it. This is
|
|
36
|
+
* the last-resort backend; streaming players never restart on edits.
|
|
37
|
+
*/
|
|
38
|
+
public async play(score: TrackScore, beat = 0): Promise<void> {
|
|
39
|
+
await this.stopAsync();
|
|
40
|
+
if (process.env.DAWG_AUDIO === "0") return;
|
|
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
|
+
}
|
|
47
|
+
const path = `/tmp/dawg-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.wav`;
|
|
48
|
+
await Bun.write(path, renderLoopWav(score, beat, samples));
|
|
49
|
+
this.file = path;
|
|
50
|
+
this.spawn(path);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private spawn(path: string): void {
|
|
54
|
+
const command =
|
|
55
|
+
process.platform === "darwin"
|
|
56
|
+
? ["afplay", path]
|
|
57
|
+
: [
|
|
58
|
+
"ffplay",
|
|
59
|
+
"-nodisp",
|
|
60
|
+
"-autoexit",
|
|
61
|
+
"-loglevel",
|
|
62
|
+
"quiet",
|
|
63
|
+
"-loop",
|
|
64
|
+
"0",
|
|
65
|
+
path,
|
|
66
|
+
];
|
|
67
|
+
try {
|
|
68
|
+
const child = Bun.spawn(command, { stdout: "ignore", stderr: "ignore" });
|
|
69
|
+
this.process = child;
|
|
70
|
+
void child.exited.then(() => {
|
|
71
|
+
if (this.process !== child) return;
|
|
72
|
+
this.process = undefined;
|
|
73
|
+
// afplay is one-shot, so restart the same rendered loop while the
|
|
74
|
+
// session still owns the playback lock. Stop clears `this.file` first.
|
|
75
|
+
if (this.file === path && this.ownsLock) {
|
|
76
|
+
this.spawn(path);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
void this.cleanup().then(() => this.releaseLock());
|
|
80
|
+
});
|
|
81
|
+
} catch {
|
|
82
|
+
void this.cleanup().then(() => this.releaseLock());
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
public stop(): void {
|
|
87
|
+
void this.stopAsync();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private async stopAsync(): Promise<void> {
|
|
91
|
+
const child = this.process;
|
|
92
|
+
this.process = undefined;
|
|
93
|
+
child?.kill();
|
|
94
|
+
await child?.exited;
|
|
95
|
+
await this.cleanup();
|
|
96
|
+
await this.releaseLock();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private async acquireLock(): Promise<boolean> {
|
|
100
|
+
return this.lock.acquire();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private async releaseLock(): Promise<void> {
|
|
104
|
+
await this.lock.release();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private async cleanup(): Promise<void> {
|
|
108
|
+
if (!this.file) return;
|
|
109
|
+
const path = this.file;
|
|
110
|
+
this.file = undefined;
|
|
111
|
+
await unlink(path).catch(() => undefined);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A loop-folded WAV whose first frame is `beat` into the loop. */
|
|
116
|
+
export function renderLoopWav(
|
|
117
|
+
score: TrackScore,
|
|
118
|
+
beat = 0,
|
|
119
|
+
samples?: SampleBank,
|
|
120
|
+
): Uint8Array {
|
|
121
|
+
const audio = renderScorePcm(score, { loop: true, samples });
|
|
122
|
+
const framesPerBeat = (60 * audio.sampleRate) / score.tempoBpm;
|
|
123
|
+
const start =
|
|
124
|
+
((Math.round(Math.max(0, beat) * framesPerBeat) % audio.frames) +
|
|
125
|
+
audio.frames) %
|
|
126
|
+
audio.frames;
|
|
127
|
+
const rotated = new Int16Array(audio.pcm.length);
|
|
128
|
+
rotated.set(audio.pcm.subarray(start * RENDER_CHANNELS));
|
|
129
|
+
rotated.set(
|
|
130
|
+
audio.pcm.subarray(0, start * RENDER_CHANNELS),
|
|
131
|
+
(audio.frames - start) * RENDER_CHANNELS,
|
|
132
|
+
);
|
|
133
|
+
return encodeWav(rotated, audio.sampleRate, RENDER_CHANNELS);
|
|
134
|
+
}
|
|
@@ -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 {}
|