@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
package/src/audio/wav.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
SCORE_LIMITS,
|
|
3
|
+
isSamplerInstrument,
|
|
3
4
|
isTrackAudible,
|
|
4
5
|
type AutomationPoint,
|
|
5
6
|
type Note,
|
|
@@ -7,6 +8,12 @@ import {
|
|
|
7
8
|
type TrackScore,
|
|
8
9
|
} from "../../core/score.ts";
|
|
9
10
|
import { drumVoiceForPitch, isDrumInstrument } from "../../core/drums.ts";
|
|
11
|
+
import {
|
|
12
|
+
planSamplerVoices,
|
|
13
|
+
renderSamplerVoices,
|
|
14
|
+
samplerTailSeconds,
|
|
15
|
+
} from "./sampler.ts";
|
|
16
|
+
import { EMPTY_SAMPLE_BANK, sampleKey, type SampleBank } from "./samples.ts";
|
|
10
17
|
|
|
11
18
|
export type WavOptions = Readonly<{ sampleRate?: number; maxSeconds?: number }>;
|
|
12
19
|
|
|
@@ -18,6 +25,11 @@ export type RenderOptions = WavOptions &
|
|
|
18
25
|
* what the streaming engine plays; exports keep the default one-shot.
|
|
19
26
|
*/
|
|
20
27
|
loop?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Decoded sampler voices (`SampleLibrary.load`). Without it sampler
|
|
30
|
+
* tracks render silent; scores without samplers are unaffected.
|
|
31
|
+
*/
|
|
32
|
+
samples?: SampleBank;
|
|
21
33
|
}>;
|
|
22
34
|
|
|
23
35
|
/** Interleaved stereo 16-bit PCM plus its frame count. */
|
|
@@ -83,7 +95,7 @@ export function clampSampleRate(sampleRate?: number): number {
|
|
|
83
95
|
/** Render the bounded score to a stereo 16-bit PCM WAV. */
|
|
84
96
|
export function renderScoreWav(
|
|
85
97
|
score: TrackScore,
|
|
86
|
-
options: WavOptions = {},
|
|
98
|
+
options: WavOptions & Pick<RenderOptions, "samples"> = {},
|
|
87
99
|
): Uint8Array {
|
|
88
100
|
const audio = renderScorePcm(score, options);
|
|
89
101
|
return encodeWav(audio.pcm, audio.sampleRate, RENDER_CHANNELS);
|
|
@@ -98,92 +110,294 @@ export function renderScorePcm(
|
|
|
98
110
|
score: TrackScore,
|
|
99
111
|
options: RenderOptions = {},
|
|
100
112
|
): RenderedAudio {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
113
|
+
return new StemRenderer({ maxCacheBytes: 0 }).render(score, options);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Default byte budget for cached per-track stems. */
|
|
117
|
+
export const DEFAULT_STEM_CACHE_BYTES = 256 * 1024 * 1024;
|
|
118
|
+
|
|
119
|
+
type Stem = {
|
|
120
|
+
key: string;
|
|
121
|
+
left: Float64Array;
|
|
122
|
+
right: Float64Array;
|
|
123
|
+
bytes: number;
|
|
124
|
+
used: number;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The renderer behind `renderScorePcm`, with a per-track stem cache. Each
|
|
129
|
+
* track's stereo stem (after its own effects) is keyed by everything that
|
|
130
|
+
* shapes it: the track settings, its notes, tempo, tick resolution, sample
|
|
131
|
+
* rate and buffer length. Edits re-render only the tracks whose key changed
|
|
132
|
+
* and sum the cached rest in the original order, so the result is
|
|
133
|
+
* byte-identical to a cold render; mute and solo only pick which stems are
|
|
134
|
+
* summed. Scratch buffers are reused across renders.
|
|
135
|
+
*/
|
|
136
|
+
export class StemRenderer {
|
|
137
|
+
private readonly maxCacheBytes: number;
|
|
138
|
+
private readonly stems = new Map<string, Stem>();
|
|
139
|
+
private cacheBytes = 0;
|
|
140
|
+
private renders = 0;
|
|
141
|
+
private scratch: {
|
|
142
|
+
dry: Float64Array;
|
|
143
|
+
left: Float64Array;
|
|
144
|
+
right: Float64Array;
|
|
145
|
+
mixL: Float64Array;
|
|
146
|
+
mixR: Float64Array;
|
|
147
|
+
} = {
|
|
148
|
+
dry: new Float64Array(0),
|
|
149
|
+
left: new Float64Array(0),
|
|
150
|
+
right: new Float64Array(0),
|
|
151
|
+
mixL: new Float64Array(0),
|
|
152
|
+
mixR: new Float64Array(0),
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
public constructor(options: Readonly<{ maxCacheBytes?: number }> = {}) {
|
|
156
|
+
this.maxCacheBytes = Math.max(
|
|
157
|
+
0,
|
|
158
|
+
options.maxCacheBytes ?? DEFAULT_STEM_CACHE_BYTES,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Cached stems and their total size, for tests and diagnostics. */
|
|
163
|
+
public get cache(): Readonly<{ stems: number; bytes: number }> {
|
|
164
|
+
return { stems: this.stems.size, bytes: this.cacheBytes };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
public render(score: TrackScore, options: RenderOptions = {}): RenderedAudio {
|
|
168
|
+
const sampleRate = clampSampleRate(options.sampleRate);
|
|
169
|
+
const maxSeconds = Math.max(1, Math.min(60, options.maxSeconds ?? 30));
|
|
170
|
+
const loopSeconds = (score.bars * score.beatsPerBar * 60) / score.tempoBpm;
|
|
171
|
+
const reverbTail = Math.max(
|
|
172
|
+
0,
|
|
173
|
+
...score.tracks.map((track) =>
|
|
174
|
+
track.reverb && track.reverb.mix > 0 ? 1 + 3 * track.reverb.size : 0,
|
|
118
175
|
),
|
|
119
176
|
);
|
|
120
|
-
const
|
|
177
|
+
const bank = options.samples ?? EMPTY_SAMPLE_BANK;
|
|
178
|
+
// Zero without sampler voices, so synth-only renders are unchanged.
|
|
179
|
+
const samplerTail = Math.min(
|
|
121
180
|
MAX_LOOP_TAIL_SECONDS,
|
|
122
|
-
|
|
123
|
-
);
|
|
124
|
-
samples = frames + Math.ceil(tailSeconds * sampleRate);
|
|
125
|
-
} else {
|
|
126
|
-
const seconds = Math.min(
|
|
127
|
-
maxSeconds,
|
|
128
|
-
loopSeconds + ONE_SHOT_TAIL_SECONDS + reverbTail,
|
|
181
|
+
samplerTailSeconds(score, bank, sampleRate),
|
|
129
182
|
);
|
|
130
|
-
frames
|
|
131
|
-
samples
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const track = tracks.get(trackId);
|
|
156
|
-
dry.fill(0);
|
|
157
|
-
const drums = isDrumInstrument(track?.instrument);
|
|
158
|
-
for (const note of notes) {
|
|
159
|
-
if (drums) renderDrumNote(dry, note, track, context);
|
|
160
|
-
else renderToneNote(dry, note, track, context);
|
|
183
|
+
let frames: number;
|
|
184
|
+
let samples: number;
|
|
185
|
+
if (options.loop) {
|
|
186
|
+
frames = Math.max(
|
|
187
|
+
1,
|
|
188
|
+
Math.min(
|
|
189
|
+
Math.round(loopFrames(score, sampleRate)),
|
|
190
|
+
Math.ceil(maxSeconds * sampleRate),
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
const tailSeconds = Math.min(
|
|
194
|
+
MAX_LOOP_TAIL_SECONDS,
|
|
195
|
+
Math.max(MAX_DRUM_SECONDS, samplerTail) +
|
|
196
|
+
0.1 +
|
|
197
|
+
reverbTail +
|
|
198
|
+
delayTailSeconds(score),
|
|
199
|
+
);
|
|
200
|
+
samples = frames + Math.ceil(tailSeconds * sampleRate);
|
|
201
|
+
} else {
|
|
202
|
+
const seconds = Math.min(
|
|
203
|
+
maxSeconds,
|
|
204
|
+
loopSeconds + Math.max(ONE_SHOT_TAIL_SECONDS, samplerTail) + reverbTail,
|
|
205
|
+
);
|
|
206
|
+
frames = Math.max(1, Math.ceil(seconds * sampleRate));
|
|
207
|
+
samples = frames;
|
|
161
208
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
209
|
+
const context: RenderContext = {
|
|
210
|
+
score,
|
|
211
|
+
sampleRate,
|
|
212
|
+
samples,
|
|
213
|
+
samplesPerTick: (sampleRate * 60) / (score.tempoBpm * score.ticksPerBeat),
|
|
214
|
+
};
|
|
215
|
+
this.renders += 1;
|
|
216
|
+
const { dry, left, right, mixL, mixR } = this.scratchFor(samples);
|
|
217
|
+
mixL.fill(0);
|
|
218
|
+
mixR.fill(0);
|
|
219
|
+
const tracks = new Map(score.tracks.map((track) => [track.id, track]));
|
|
220
|
+
const groups = new Map<string, Note[]>();
|
|
221
|
+
for (const note of score.notes) {
|
|
222
|
+
const group = groups.get(note.trackId);
|
|
223
|
+
if (group) group.push(note);
|
|
224
|
+
else groups.set(note.trackId, [note]);
|
|
167
225
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
226
|
+
// Tracks render one at a time; the sum order (first note per track) is
|
|
227
|
+
// part of the output, so cached and cold renders keep it.
|
|
228
|
+
for (const [trackId, notes] of groups) {
|
|
229
|
+
if (!isTrackAudible(score, trackId)) continue;
|
|
230
|
+
const track = tracks.get(trackId);
|
|
231
|
+
const sampler = isSamplerInstrument(track?.instrument);
|
|
232
|
+
const key = stemKey(track, notes, context, sampler ? bank : undefined);
|
|
233
|
+
let stem = this.stems.get(trackId);
|
|
234
|
+
if (stem?.key === key) stem.used = this.renders;
|
|
235
|
+
else {
|
|
236
|
+
const caching = this.maxCacheBytes > 0;
|
|
237
|
+
const target = caching
|
|
238
|
+
? {
|
|
239
|
+
left: new Float64Array(samples),
|
|
240
|
+
right: new Float64Array(samples),
|
|
241
|
+
}
|
|
242
|
+
: { left, right };
|
|
243
|
+
dry.fill(0);
|
|
244
|
+
if (sampler) {
|
|
245
|
+
if (track) renderSamplerNotes(dry, notes, track, context, bank);
|
|
246
|
+
} else {
|
|
247
|
+
const drums = isDrumInstrument(track?.instrument);
|
|
248
|
+
for (const note of notes) {
|
|
249
|
+
if (drums) renderDrumNote(dry, note, track, context);
|
|
250
|
+
else renderToneNote(dry, note, track, context);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (track) applyLowPass(dry, track, context);
|
|
254
|
+
applyPan(dry, target.left, target.right, track, context);
|
|
255
|
+
if (track) {
|
|
256
|
+
applyDelay(target.left, target.right, track, context);
|
|
257
|
+
applyReverb(target.left, target.right, track, context);
|
|
258
|
+
}
|
|
259
|
+
stem = {
|
|
260
|
+
key,
|
|
261
|
+
left: target.left,
|
|
262
|
+
right: target.right,
|
|
263
|
+
bytes: target.left.byteLength + target.right.byteLength,
|
|
264
|
+
used: this.renders,
|
|
265
|
+
};
|
|
266
|
+
if (caching) this.store(trackId, stem);
|
|
267
|
+
}
|
|
268
|
+
for (let index = 0; index < samples; index += 1) {
|
|
269
|
+
mixL[index]! += stem.left[index]!;
|
|
270
|
+
mixR[index]! += stem.right[index]!;
|
|
271
|
+
}
|
|
171
272
|
}
|
|
273
|
+
// Stems of tracks that left the score are not worth keeping; muted and
|
|
274
|
+
// unsoloed tracks keep theirs so toggling them back is free.
|
|
275
|
+
for (const trackId of [...this.stems.keys()])
|
|
276
|
+
if (!groups.has(trackId)) this.evict(trackId);
|
|
277
|
+
if (samples > frames) {
|
|
278
|
+
// Linear effects superpose, so folding the tail onto the start yields
|
|
279
|
+
// the steady state of the loop playing forever.
|
|
280
|
+
for (let index = frames; index < samples; index += 1) {
|
|
281
|
+
mixL[index % frames]! += mixL[index]!;
|
|
282
|
+
mixR[index % frames]! += mixR[index]!;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const pcm = new Int16Array(frames * RENDER_CHANNELS);
|
|
286
|
+
for (let index = 0; index < frames; index += 1) {
|
|
287
|
+
pcm[index * 2] = clamp16(mixL[index]! * 32767);
|
|
288
|
+
pcm[index * 2 + 1] = clamp16(mixR[index]! * 32767);
|
|
289
|
+
}
|
|
290
|
+
return Object.freeze({
|
|
291
|
+
sampleRate,
|
|
292
|
+
channels: RENDER_CHANNELS,
|
|
293
|
+
frames,
|
|
294
|
+
pcm,
|
|
295
|
+
});
|
|
172
296
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
297
|
+
|
|
298
|
+
private scratchFor(samples: number) {
|
|
299
|
+
if (this.scratch.dry.length < samples) {
|
|
300
|
+
this.scratch = {
|
|
301
|
+
dry: new Float64Array(samples),
|
|
302
|
+
left: new Float64Array(samples),
|
|
303
|
+
right: new Float64Array(samples),
|
|
304
|
+
mixL: new Float64Array(samples),
|
|
305
|
+
mixR: new Float64Array(samples),
|
|
306
|
+
};
|
|
179
307
|
}
|
|
308
|
+
const view = (buffer: Float64Array) => buffer.subarray(0, samples);
|
|
309
|
+
return {
|
|
310
|
+
dry: view(this.scratch.dry),
|
|
311
|
+
left: view(this.scratch.left),
|
|
312
|
+
right: view(this.scratch.right),
|
|
313
|
+
mixL: view(this.scratch.mixL),
|
|
314
|
+
mixR: view(this.scratch.mixR),
|
|
315
|
+
};
|
|
180
316
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
317
|
+
|
|
318
|
+
private store(trackId: string, stem: Stem): void {
|
|
319
|
+
this.evict(trackId);
|
|
320
|
+
// Make room by dropping the least recently used stems from other
|
|
321
|
+
// renders; a score whose stems exceed the budget is simply not cached.
|
|
322
|
+
while (
|
|
323
|
+
this.cacheBytes + stem.bytes > this.maxCacheBytes &&
|
|
324
|
+
this.stems.size > 0
|
|
325
|
+
) {
|
|
326
|
+
let oldest: string | undefined;
|
|
327
|
+
for (const [id, candidate] of this.stems)
|
|
328
|
+
if (
|
|
329
|
+
candidate.used !== this.renders &&
|
|
330
|
+
(oldest === undefined ||
|
|
331
|
+
candidate.used < this.stems.get(oldest)!.used)
|
|
332
|
+
)
|
|
333
|
+
oldest = id;
|
|
334
|
+
if (oldest === undefined) break;
|
|
335
|
+
this.evict(oldest);
|
|
336
|
+
}
|
|
337
|
+
if (this.cacheBytes + stem.bytes > this.maxCacheBytes) return;
|
|
338
|
+
this.stems.set(trackId, stem);
|
|
339
|
+
this.cacheBytes += stem.bytes;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private evict(trackId: string): void {
|
|
343
|
+
const stem = this.stems.get(trackId);
|
|
344
|
+
if (!stem) return;
|
|
345
|
+
this.stems.delete(trackId);
|
|
346
|
+
this.cacheBytes -= stem.bytes;
|
|
185
347
|
}
|
|
186
|
-
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Everything a track's stem depends on, except mute and solo. */
|
|
351
|
+
function stemKey(
|
|
352
|
+
track: Track | undefined,
|
|
353
|
+
notes: readonly Note[],
|
|
354
|
+
context: RenderContext,
|
|
355
|
+
bank?: SampleBank,
|
|
356
|
+
): string {
|
|
357
|
+
let settings: Record<string, unknown> | null = null;
|
|
358
|
+
if (track) {
|
|
359
|
+
const { muted: _muted, solo: _solo, ...rest } = track;
|
|
360
|
+
settings = rest;
|
|
361
|
+
}
|
|
362
|
+
return JSON.stringify([
|
|
363
|
+
settings,
|
|
364
|
+
notes,
|
|
365
|
+
context.score.tempoBpm,
|
|
366
|
+
context.score.ticksPerBeat,
|
|
367
|
+
context.sampleRate,
|
|
368
|
+
context.samples,
|
|
369
|
+
// Sampler stems also depend on the decoded files: a replaced or missing
|
|
370
|
+
// sample changes the key even when the score did not change.
|
|
371
|
+
...(bank && track?.sampler ? [samplerVoiceDigest(track, bank)] : []),
|
|
372
|
+
]);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function samplerVoiceDigest(track: Track, bank: SampleBank): string[] {
|
|
376
|
+
return Object.keys(track.sampler?.voices ?? {})
|
|
377
|
+
.sort()
|
|
378
|
+
.map((voice) => {
|
|
379
|
+
const sample = bank.voices.get(sampleKey(track.id, voice));
|
|
380
|
+
return sample
|
|
381
|
+
? `${voice}:${sample.sha256}:${sample.sampleRate}:${sample.frames}`
|
|
382
|
+
: `${voice}:-`;
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Sample voices of a sampler track into its mono dry buffer. */
|
|
387
|
+
function renderSamplerNotes(
|
|
388
|
+
target: Float64Array,
|
|
389
|
+
notes: readonly Note[],
|
|
390
|
+
track: Track,
|
|
391
|
+
context: RenderContext,
|
|
392
|
+
bank: SampleBank,
|
|
393
|
+
): void {
|
|
394
|
+
const timing = { score: context.score, sampleRate: context.sampleRate };
|
|
395
|
+
renderSamplerVoices(
|
|
396
|
+
target,
|
|
397
|
+
planSamplerVoices(track, notes, bank, timing),
|
|
398
|
+
timing,
|
|
399
|
+
(tick) => trackGainAt(track, tick),
|
|
400
|
+
);
|
|
187
401
|
}
|
|
188
402
|
|
|
189
403
|
/** Seconds until the slowest delay decays below -60 dB, bounded. */
|
package/src/auth/cli.ts
CHANGED
|
@@ -1,26 +1,44 @@
|
|
|
1
1
|
import { hostname } from "node:os";
|
|
2
2
|
import { createInterface } from "node:readline/promises";
|
|
3
3
|
import { stdin, stdout } from "node:process";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
defaultAuthEnv,
|
|
6
|
+
readConfig,
|
|
7
|
+
writeConfig,
|
|
8
|
+
type DawgConfig,
|
|
9
|
+
} from "./credentials.ts";
|
|
5
10
|
import {
|
|
6
11
|
authStatus,
|
|
12
|
+
isLogoutTarget,
|
|
7
13
|
login,
|
|
8
14
|
logout,
|
|
15
|
+
modelCommand,
|
|
9
16
|
type LoginIO,
|
|
10
|
-
type
|
|
17
|
+
type LoginOptions,
|
|
18
|
+
type LoginTarget,
|
|
11
19
|
} from "./login.ts";
|
|
12
|
-
import {
|
|
20
|
+
import { LOGIN_OPTIONS } from "./discover.ts";
|
|
21
|
+
import { chooseOnTerminal } from "./picker.ts";
|
|
22
|
+
import { systemRunner, type CommandRunner } from "./runner.ts";
|
|
23
|
+
import { selectProvider } from "../agent/provider.ts";
|
|
13
24
|
import { audioStatusLine } from "../audio/engine.ts";
|
|
14
25
|
|
|
15
26
|
export const AUTH_USAGE = `Usage:
|
|
16
|
-
dawg login
|
|
17
|
-
dawg login
|
|
18
|
-
dawg login
|
|
19
|
-
dawg login
|
|
20
|
-
dawg
|
|
21
|
-
dawg
|
|
27
|
+
dawg login find existing setups and pick a provider (reuses a saved one)
|
|
28
|
+
dawg login gateway Vercel AI Gateway: existing key, or create one with the Vercel CLI
|
|
29
|
+
dawg login openrouter OpenRouter: sign in through your browser (OAuth PKCE)
|
|
30
|
+
dawg login codex ChatGPT/Codex subscription through xcb
|
|
31
|
+
dawg login claude Claude subscription through xcb
|
|
32
|
+
dawg login --pick show the picker even when a provider is saved
|
|
33
|
+
dawg login --key paste an AI Gateway key (hidden input)
|
|
34
|
+
dawg login --xcb any xcb account (Devin included)
|
|
35
|
+
dawg login gateway --budget <dollars> spend limit for a created key
|
|
36
|
+
dawg login codex|claude [--account <id>] [--model <key>]
|
|
37
|
+
dawg model [<alias>|<vendor/model>] pick the model (cost per prompt shown)
|
|
38
|
+
dawg logout [gateway|openrouter|codex|claude|xcb] forget keys and the saved choice
|
|
39
|
+
dawg auth status [--check] every provider: detected, active, validated`;
|
|
22
40
|
|
|
23
|
-
/** Entry for `dawg login|logout|auth`. Returns the process exit code. */
|
|
41
|
+
/** Entry for `dawg login|logout|auth|model`. Returns the process exit code. */
|
|
24
42
|
export async function runAuthCommand(argv: readonly string[]): Promise<number> {
|
|
25
43
|
const [command, ...rest] = argv;
|
|
26
44
|
const io = terminalIO();
|
|
@@ -29,7 +47,15 @@ export async function runAuthCommand(argv: readonly string[]): Promise<number> {
|
|
|
29
47
|
io.print(AUTH_USAGE);
|
|
30
48
|
return 0;
|
|
31
49
|
}
|
|
32
|
-
if (command === "logout")
|
|
50
|
+
if (command === "logout") {
|
|
51
|
+
const target = rest[0];
|
|
52
|
+
if (target !== undefined && !isLogoutTarget(target)) {
|
|
53
|
+
io.print(AUTH_USAGE);
|
|
54
|
+
return 2;
|
|
55
|
+
}
|
|
56
|
+
return logout(deps, target);
|
|
57
|
+
}
|
|
58
|
+
if (command === "model") return modelCommand(deps, rest[0]);
|
|
33
59
|
if (command === "auth") {
|
|
34
60
|
if (
|
|
35
61
|
rest[0] !== undefined &&
|
|
@@ -41,30 +67,117 @@ export async function runAuthCommand(argv: readonly string[]): Promise<number> {
|
|
|
41
67
|
}
|
|
42
68
|
for (const line of await authStatus(deps, {
|
|
43
69
|
verify: rest.includes("--check"),
|
|
70
|
+
onProgress: (line) => io.print(line),
|
|
44
71
|
}))
|
|
45
72
|
io.print(line);
|
|
46
73
|
io.print(audioStatusLine());
|
|
47
74
|
return 0;
|
|
48
75
|
}
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
76
|
+
const parsed = parseLoginArgs(rest);
|
|
77
|
+
if (typeof parsed === "string") {
|
|
78
|
+
io.print(parsed);
|
|
79
|
+
io.print(AUTH_USAGE);
|
|
80
|
+
return 2;
|
|
81
|
+
}
|
|
82
|
+
return login(parsed.target, deps, parsed.options);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The first run of `dawg` in a terminal: when nothing is configured (and the
|
|
87
|
+
* picker was never dismissed), or a saved sign-in stopped working, show the
|
|
88
|
+
* sign-in picker before the TUI starts. Dismissing it is remembered.
|
|
89
|
+
*/
|
|
90
|
+
export async function runFirstRunLogin(
|
|
91
|
+
runner: CommandRunner = systemRunner,
|
|
92
|
+
): Promise<void> {
|
|
93
|
+
const auth = defaultAuthEnv(runner);
|
|
94
|
+
const config = await readConfig(auth).catch(() => ({}) as DawgConfig);
|
|
95
|
+
if (auth.env.DAWG_PROVIDER) return;
|
|
96
|
+
if (!config.provider && config.setup === "skipped") return;
|
|
97
|
+
const selection = await selectProvider(auth).catch(() => undefined);
|
|
98
|
+
if (!selection || selection.kind !== "offline") return;
|
|
99
|
+
if (config.provider && !selection.invalidSaved) return;
|
|
100
|
+
const io = terminalIO();
|
|
101
|
+
if (!io.interactive) return;
|
|
102
|
+
if (selection.invalidSaved)
|
|
103
|
+
io.print(`Your saved sign-in stopped working: ${selection.reason}.`);
|
|
104
|
+
else io.print("Welcome to dawg. Sign in to enable the agent (Esc to skip).");
|
|
105
|
+
const code = await login("pick", {
|
|
106
|
+
auth,
|
|
107
|
+
io,
|
|
108
|
+
hostname: hostname(),
|
|
109
|
+
}).catch((error: unknown) => {
|
|
110
|
+
io.print(
|
|
111
|
+
`sign-in failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
112
|
+
);
|
|
113
|
+
return 1;
|
|
114
|
+
});
|
|
115
|
+
if (code !== 0 && !config.provider)
|
|
116
|
+
await writeConfig(auth, { setup: "skipped" }).catch(() => undefined);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `/login` in the TUI, run while the screen is handed back to the shell. */
|
|
120
|
+
export async function runTuiLogin(
|
|
121
|
+
target: LoginTarget,
|
|
122
|
+
options: LoginOptions,
|
|
123
|
+
runner: CommandRunner = systemRunner,
|
|
124
|
+
): Promise<number> {
|
|
125
|
+
const io = terminalIO();
|
|
126
|
+
return login(
|
|
127
|
+
target,
|
|
128
|
+
{
|
|
129
|
+
auth: defaultAuthEnv(runner),
|
|
130
|
+
io,
|
|
131
|
+
hostname: hostname(),
|
|
132
|
+
},
|
|
133
|
+
options,
|
|
134
|
+
).catch((error: unknown) => {
|
|
135
|
+
io.print(
|
|
136
|
+
`sign-in failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
137
|
+
);
|
|
138
|
+
return 1;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function parseLoginArgs(
|
|
143
|
+
args: readonly string[],
|
|
144
|
+
): { target: LoginTarget; options: LoginOptions } | string {
|
|
145
|
+
const options: LoginOptions = {};
|
|
146
|
+
let target: LoginTarget = "auto";
|
|
147
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
148
|
+
const arg = args[index]!;
|
|
149
|
+
const value = () => {
|
|
150
|
+
const next = args[index + 1];
|
|
151
|
+
index += 1;
|
|
152
|
+
return next && !next.startsWith("--") ? next : undefined;
|
|
153
|
+
};
|
|
154
|
+
if ((LOGIN_OPTIONS as readonly string[]).includes(arg))
|
|
155
|
+
target = arg as LoginTarget;
|
|
156
|
+
else if (arg === "--gateway") target = "gateway";
|
|
157
|
+
else if (arg === "--openrouter") target = "openrouter";
|
|
158
|
+
else if (arg === "--xcb") target = "xcb";
|
|
159
|
+
else if (arg === "--key") target = "key";
|
|
160
|
+
else if (arg === "--pick") target = "pick";
|
|
161
|
+
else if (arg === "--budget") {
|
|
162
|
+
const budget = Number(value());
|
|
163
|
+
if (!Number.isFinite(budget) || budget < 1 || budget > 1_000_000)
|
|
164
|
+
return "--budget takes a dollar amount of at least 1";
|
|
165
|
+
options.budget = budget;
|
|
166
|
+
if (target === "auto") target = "gateway";
|
|
167
|
+
} else if (arg === "--account") {
|
|
168
|
+
const account = value();
|
|
169
|
+
if (!account) return "--account needs an account id";
|
|
170
|
+
options.account = account;
|
|
171
|
+
} else if (arg === "--model") {
|
|
172
|
+
const model = value();
|
|
173
|
+
if (!model) return "--model needs a model key";
|
|
174
|
+
options.model = model;
|
|
175
|
+
} else return `unknown argument: ${arg.slice(0, 40)}`;
|
|
63
176
|
}
|
|
64
|
-
return
|
|
177
|
+
return { target, options };
|
|
65
178
|
}
|
|
66
179
|
|
|
67
|
-
function terminalIO(): LoginIO {
|
|
180
|
+
export function terminalIO(): LoginIO {
|
|
68
181
|
return {
|
|
69
182
|
interactive: Boolean(stdin.isTTY && stdout.isTTY),
|
|
70
183
|
print: (line) => void stdout.write(`${line}\n`),
|
|
@@ -77,6 +190,12 @@ function terminalIO(): LoginIO {
|
|
|
77
190
|
}
|
|
78
191
|
},
|
|
79
192
|
readSecret: (prompt) => readHidden(prompt),
|
|
193
|
+
...(stdin.isTTY && stdout.isTTY
|
|
194
|
+
? {
|
|
195
|
+
choose: (title, items, options) =>
|
|
196
|
+
chooseOnTerminal(title, items, options),
|
|
197
|
+
}
|
|
198
|
+
: {}),
|
|
80
199
|
};
|
|
81
200
|
}
|
|
82
201
|
|