@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
package/core/sdk/v1.ts
ADDED
|
@@ -0,0 +1,954 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dawg SDK v1: pure builders for `song.ts` and `tracks/<slug>/track.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Author everything in beats; `song()` converts to the integer ticks the
|
|
5
|
+
* score stores. Every function returns frozen plain data, does no I/O and
|
|
6
|
+
* has no dependencies, so this file is vendored unchanged into
|
|
7
|
+
* `.dawg/sdk/v1.ts` and imported as `"dawg"`:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* // tracks/bass/track.ts
|
|
11
|
+
* import { track, note, seq } from "dawg";
|
|
12
|
+
* export default track({
|
|
13
|
+
* name: "bass",
|
|
14
|
+
* instrument: "bass",
|
|
15
|
+
* volume: 0.8,
|
|
16
|
+
* notes: [note("A1", 0, 1), ...seq("E2 G2 A2", { from: 4, step: 0.5 })],
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* // song.ts
|
|
20
|
+
* import { song } from "dawg";
|
|
21
|
+
* import bass from "./tracks/bass/track.ts";
|
|
22
|
+
* export default song({ tempo: 120, meter: [4, 4], bars: 4, tracks: [bass] });
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Additions to v1 are backwards compatible: new optional fields default to
|
|
26
|
+
* the old behaviour and files that do not use them reprint byte-for-byte.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** SDK release; dawg refreshes the vendored copy when its own is newer. */
|
|
30
|
+
export const SDK_VERSION = "1.0.0";
|
|
31
|
+
/** Major of `SDK_VERSION`; `dawg.json` records it as `sdk`. */
|
|
32
|
+
export const SDK_MAJOR = 1;
|
|
33
|
+
|
|
34
|
+
/** Ticks per beat the score uses unless `song({ ticksPerBeat })` says otherwise. */
|
|
35
|
+
export const DEFAULT_TICKS_PER_BEAT = 480;
|
|
36
|
+
/** Velocity used when a note or hit omits it. */
|
|
37
|
+
export const DEFAULT_VELOCITY = 0.8;
|
|
38
|
+
/** Length in beats used when a hit omits it (a sixteenth at 4/4). */
|
|
39
|
+
export const DEFAULT_HIT_LENGTH = 0.25;
|
|
40
|
+
|
|
41
|
+
/** A MIDI number 0..127 or a pitch name such as `"C4"`, `"F#2"`, `"Bb3"`. */
|
|
42
|
+
export type Pitch = number | string;
|
|
43
|
+
|
|
44
|
+
/** Thrown by every builder when its input is malformed; the message names the field. */
|
|
45
|
+
export class DawgSdkError extends Error {
|
|
46
|
+
constructor(message: string) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = "DawgSdkError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Pitches
|
|
54
|
+
|
|
55
|
+
const SEMITONES: Readonly<Record<string, number>> = Object.freeze({
|
|
56
|
+
c: 0,
|
|
57
|
+
d: 2,
|
|
58
|
+
e: 4,
|
|
59
|
+
f: 5,
|
|
60
|
+
g: 7,
|
|
61
|
+
a: 9,
|
|
62
|
+
b: 11,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* MIDI number for a pitch name (`"A4"` → 69, `"C#3"` → 49, `"Bb2"` → 46;
|
|
67
|
+
* octaves -1..9, case-insensitive). Numbers 0..127 pass through.
|
|
68
|
+
* Throws `DawgSdkError` otherwise.
|
|
69
|
+
*/
|
|
70
|
+
export function midi(pitch: Pitch): number {
|
|
71
|
+
if (typeof pitch === "number") {
|
|
72
|
+
if (Number.isInteger(pitch) && pitch >= 0 && pitch <= 127) return pitch;
|
|
73
|
+
throw new DawgSdkError(`pitch must be a MIDI integer 0..127: ${pitch}`);
|
|
74
|
+
}
|
|
75
|
+
const match =
|
|
76
|
+
typeof pitch === "string"
|
|
77
|
+
? pitch.trim().match(/^([a-gA-G])([#b]?)(-?\d{1,2})$/)
|
|
78
|
+
: null;
|
|
79
|
+
if (!match)
|
|
80
|
+
throw new DawgSdkError(
|
|
81
|
+
`pitch must be a name like "C4" or "F#2": ${JSON.stringify(pitch)}`,
|
|
82
|
+
);
|
|
83
|
+
const accidental = match[2] === "#" ? 1 : match[2] === "b" ? -1 : 0;
|
|
84
|
+
const value =
|
|
85
|
+
(Number(match[3]) + 1) * 12 +
|
|
86
|
+
SEMITONES[match[1]!.toLowerCase()]! +
|
|
87
|
+
accidental;
|
|
88
|
+
if (value < 0 || value > 127)
|
|
89
|
+
throw new DawgSdkError(`pitch is outside MIDI 0..127: ${pitch}`);
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Drum voices (General MIDI numbers, same table as dawg's `kit`)
|
|
95
|
+
|
|
96
|
+
/** Drum voice names a `kit` track understands in `hit()`. */
|
|
97
|
+
export type DrumVoice =
|
|
98
|
+
"kick" | "snare" | "clap" | "rim" | "tom" | "hat" | "openhat";
|
|
99
|
+
|
|
100
|
+
/** GM pitch stored for each kit voice; `hit("kick", 0)` writes pitch 36. */
|
|
101
|
+
export const DRUM_PITCHES: Readonly<Record<DrumVoice, number>> = Object.freeze({
|
|
102
|
+
kick: 36,
|
|
103
|
+
rim: 37,
|
|
104
|
+
snare: 38,
|
|
105
|
+
clap: 39,
|
|
106
|
+
hat: 42,
|
|
107
|
+
tom: 45,
|
|
108
|
+
openhat: 46,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const DRUM_ALIASES: Readonly<Record<string, DrumVoice>> = Object.freeze({
|
|
112
|
+
kick: "kick",
|
|
113
|
+
bd: "kick",
|
|
114
|
+
snare: "snare",
|
|
115
|
+
sd: "snare",
|
|
116
|
+
clap: "clap",
|
|
117
|
+
cp: "clap",
|
|
118
|
+
rim: "rim",
|
|
119
|
+
rimshot: "rim",
|
|
120
|
+
perc: "rim",
|
|
121
|
+
tom: "tom",
|
|
122
|
+
lt: "tom",
|
|
123
|
+
hat: "hat",
|
|
124
|
+
hh: "hat",
|
|
125
|
+
hihat: "hat",
|
|
126
|
+
"hi-hat": "hat",
|
|
127
|
+
closedhat: "hat",
|
|
128
|
+
"closed-hat": "hat",
|
|
129
|
+
chh: "hat",
|
|
130
|
+
openhat: "openhat",
|
|
131
|
+
"open-hat": "openhat",
|
|
132
|
+
ohh: "openhat",
|
|
133
|
+
oh: "openhat",
|
|
134
|
+
open: "openhat",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
/** Instrument names dawg treats as the synthesized drum kit. */
|
|
138
|
+
export const KIT_INSTRUMENTS: readonly string[] = Object.freeze([
|
|
139
|
+
"kit",
|
|
140
|
+
"drums",
|
|
141
|
+
"drum",
|
|
142
|
+
"drumkit",
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
/** Instrument name that selects a track's sampler. */
|
|
146
|
+
export const SAMPLER_INSTRUMENT = "sampler";
|
|
147
|
+
/** First pitch slot given to one-shot sampler voices (voice names sorted). */
|
|
148
|
+
export const SAMPLER_FIRST_SLOT = 36;
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Notes and hits
|
|
152
|
+
|
|
153
|
+
/** A pitched note in beats. Build with `note()` or `seq()`. */
|
|
154
|
+
export type NoteSpec = Readonly<{
|
|
155
|
+
kind: "note";
|
|
156
|
+
/** MIDI 0..127. */
|
|
157
|
+
pitch: number;
|
|
158
|
+
/** Start in beats from the loop start, ≥ 0. */
|
|
159
|
+
start: number;
|
|
160
|
+
/** Length in beats, > 0. */
|
|
161
|
+
length: number;
|
|
162
|
+
/** 0..1. */
|
|
163
|
+
velocity: number;
|
|
164
|
+
}>;
|
|
165
|
+
|
|
166
|
+
/** A drum or sampler hit addressed by voice name; resolved to a pitch slot by `track()`. */
|
|
167
|
+
export type HitSpec = Readonly<{
|
|
168
|
+
kind: "hit";
|
|
169
|
+
voice: string;
|
|
170
|
+
start: number;
|
|
171
|
+
length: number;
|
|
172
|
+
velocity: number;
|
|
173
|
+
}>;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* One note. `pitch` is a name or MIDI number, `start` and `length` are
|
|
177
|
+
* beats, `velocity` defaults to 0.8.
|
|
178
|
+
*
|
|
179
|
+
* ```ts
|
|
180
|
+
* note("A1", 0, 1) // A1 on the downbeat for one beat
|
|
181
|
+
* note("A1", 1.5, 0.5, 0.6) // off-beat eighth, softer
|
|
182
|
+
* ```
|
|
183
|
+
*/
|
|
184
|
+
export function note(
|
|
185
|
+
pitch: Pitch,
|
|
186
|
+
start: number,
|
|
187
|
+
length = 1,
|
|
188
|
+
velocity = DEFAULT_VELOCITY,
|
|
189
|
+
): NoteSpec {
|
|
190
|
+
return Object.freeze({
|
|
191
|
+
kind: "note",
|
|
192
|
+
pitch: midi(pitch),
|
|
193
|
+
start: beat(start, "note start"),
|
|
194
|
+
length: positive(length, "note length"),
|
|
195
|
+
velocity: unit(velocity, "note velocity"),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Options for `seq()`. */
|
|
200
|
+
export type SeqOptions = Readonly<{
|
|
201
|
+
/** Beat of the first step, default 0. */
|
|
202
|
+
from?: number;
|
|
203
|
+
/** Beats between steps, default 1. */
|
|
204
|
+
step?: number;
|
|
205
|
+
/** Length of each note in beats, default `step`. */
|
|
206
|
+
len?: number;
|
|
207
|
+
/** Velocity of each note, default 0.8. */
|
|
208
|
+
vel?: number;
|
|
209
|
+
}>;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A step sequence: pitches separated by spaces (or an array), one per
|
|
213
|
+
* `step` beats starting at `from`. `.`, `-` or `_` is a rest.
|
|
214
|
+
*
|
|
215
|
+
* ```ts
|
|
216
|
+
* seq("E2 G2 A2", { from: 4, step: 0.5, len: 0.5 })
|
|
217
|
+
* seq("C4 . E4 . G4", { step: 0.25 })
|
|
218
|
+
* ```
|
|
219
|
+
*/
|
|
220
|
+
export function seq(
|
|
221
|
+
pattern: string | readonly Pitch[],
|
|
222
|
+
options: SeqOptions = {},
|
|
223
|
+
): readonly NoteSpec[] {
|
|
224
|
+
const tokens =
|
|
225
|
+
typeof pattern === "string" ? pattern.trim().split(/\s+/) : [...pattern];
|
|
226
|
+
if (
|
|
227
|
+
tokens.length === 0 ||
|
|
228
|
+
tokens.length > 4096 ||
|
|
229
|
+
tokens.every((token) => token === "")
|
|
230
|
+
)
|
|
231
|
+
throw new DawgSdkError("seq pattern must have 1..4096 steps");
|
|
232
|
+
const from = beat(options.from ?? 0, "seq from");
|
|
233
|
+
const step = positive(options.step ?? 1, "seq step");
|
|
234
|
+
const len = positive(options.len ?? step, "seq len");
|
|
235
|
+
const vel = unit(options.vel ?? DEFAULT_VELOCITY, "seq vel");
|
|
236
|
+
const notes: NoteSpec[] = [];
|
|
237
|
+
tokens.forEach((token, index) => {
|
|
238
|
+
if (token === "." || token === "-" || token === "_" || token === "") return;
|
|
239
|
+
notes.push(note(token, from + index * step, len, vel));
|
|
240
|
+
});
|
|
241
|
+
return Object.freeze(notes);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* One drum or sampler hit. On a `kit` track `voice` is `kick`, `snare`,
|
|
246
|
+
* `clap`, `rim`, `tom`, `hat` or `openhat` (aliases `bd`, `sd`, `cp`, `hh`,
|
|
247
|
+
* `oh` work); on a `sampler()` track it is a voice name. `length` defaults
|
|
248
|
+
* to a sixteenth.
|
|
249
|
+
*/
|
|
250
|
+
export function hit(
|
|
251
|
+
voice: string,
|
|
252
|
+
start: number,
|
|
253
|
+
velocity = DEFAULT_VELOCITY,
|
|
254
|
+
length = DEFAULT_HIT_LENGTH,
|
|
255
|
+
): HitSpec {
|
|
256
|
+
if (typeof voice !== "string" || voice.length === 0 || voice.length > 32)
|
|
257
|
+
throw new DawgSdkError("hit voice must be a short name");
|
|
258
|
+
return Object.freeze({
|
|
259
|
+
kind: "hit",
|
|
260
|
+
voice,
|
|
261
|
+
start: beat(start, "hit start"),
|
|
262
|
+
length: positive(length, "hit length"),
|
|
263
|
+
velocity: unit(velocity, "hit velocity"),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The same hit on several beats: `hits("kick", [0, 1, 2, 3])` or
|
|
269
|
+
* `hits("hat", every(0.5, { from: 0.25 }), 0.5)`.
|
|
270
|
+
*/
|
|
271
|
+
export function hits(
|
|
272
|
+
voice: string,
|
|
273
|
+
beats: readonly number[],
|
|
274
|
+
velocity = DEFAULT_VELOCITY,
|
|
275
|
+
length = DEFAULT_HIT_LENGTH,
|
|
276
|
+
): readonly HitSpec[] {
|
|
277
|
+
if (!Array.isArray(beats) || beats.length > 4096)
|
|
278
|
+
throw new DawgSdkError("hits needs an array of at most 4096 beats");
|
|
279
|
+
return Object.freeze(beats.map((at) => hit(voice, at, velocity, length)));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Options for `every()`. */
|
|
283
|
+
export type EveryOptions = Readonly<{
|
|
284
|
+
/** First beat, default 0. */
|
|
285
|
+
from?: number;
|
|
286
|
+
/** Exclusive end in beats, default 16 (four bars of 4/4). Pass `bars * beatsPerBar`. */
|
|
287
|
+
until?: number;
|
|
288
|
+
}>;
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Beats from `from` (default 0) up to but excluding `until` (default 16)
|
|
292
|
+
* every `step` beats: `every(1)` → `[0, 1, …, 15]`,
|
|
293
|
+
* `every(0.5, { from: 0.25, until: 4 })` → `[0.25, 0.75, …, 3.75]`.
|
|
294
|
+
*/
|
|
295
|
+
export function every(step: number, options: EveryOptions = {}): number[] {
|
|
296
|
+
const size = positive(step, "every step");
|
|
297
|
+
const from = beat(options.from ?? 0, "every from");
|
|
298
|
+
const until = beat(options.until ?? 16, "every until");
|
|
299
|
+
const count = Math.max(0, Math.ceil((until - from) / size - 1e-9));
|
|
300
|
+
if (count > 4096)
|
|
301
|
+
throw new DawgSdkError("every would produce over 4096 beats");
|
|
302
|
+
const beats: number[] = [];
|
|
303
|
+
for (let index = 0; index < count; index += 1)
|
|
304
|
+
beats.push(round(from + index * size));
|
|
305
|
+
return beats;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// Sampler
|
|
310
|
+
|
|
311
|
+
/** One sample voice. A bare string is `{ src }`. */
|
|
312
|
+
export type SampleSpec = Readonly<{
|
|
313
|
+
/** Audio file: track-relative (`samples/kick.wav`) or project-relative (`tracks/x/samples/kick.wav`). */
|
|
314
|
+
src: string;
|
|
315
|
+
/** Pitch the file plays at, keyed mode only; default C4. */
|
|
316
|
+
root?: Pitch;
|
|
317
|
+
/** Start fraction 0..1 of the file, like Strudel `begin`. */
|
|
318
|
+
begin?: number;
|
|
319
|
+
/** End fraction 0..1 of the file, like Strudel `end`. */
|
|
320
|
+
end?: number;
|
|
321
|
+
/** Linear gain 0..2. */
|
|
322
|
+
gain?: number;
|
|
323
|
+
/** Playback rate, like Strudel `speed`; negative reverses. */
|
|
324
|
+
speed?: number;
|
|
325
|
+
/** Sustain by looping `begin..end`. */
|
|
326
|
+
loop?: boolean;
|
|
327
|
+
/** Choke group, like Strudel `cut`: a new hit stops the previous one in the group. */
|
|
328
|
+
choke?: string;
|
|
329
|
+
}>;
|
|
330
|
+
|
|
331
|
+
/** Result of `sampler()`; pass it as a track's `instrument`. */
|
|
332
|
+
export type SamplerSpec = Readonly<{
|
|
333
|
+
kind: "sampler";
|
|
334
|
+
voices: Readonly<Record<string, SampleSpec>>;
|
|
335
|
+
/** `oneshot` (default): voices are hits. `keyed`: voices are resampled across the keyboard from `root`. */
|
|
336
|
+
mode: "oneshot" | "keyed";
|
|
337
|
+
}>;
|
|
338
|
+
|
|
339
|
+
/** Options for `sampler()`. */
|
|
340
|
+
export type SamplerOptions = Readonly<{ mode?: "oneshot" | "keyed" }>;
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* A sample instrument. Voice names are `[A-Za-z][A-Za-z0-9_]*`, at most 64.
|
|
344
|
+
*
|
|
345
|
+
* ```ts
|
|
346
|
+
* instrument: sampler({ kick: "samples/kick.wav", snare: "samples/sd.wav" })
|
|
347
|
+
* instrument: sampler({ vox: { src: "samples/vox.wav", root: "C4" } }, { mode: "keyed" })
|
|
348
|
+
* ```
|
|
349
|
+
*
|
|
350
|
+
* One-shot voices are played with `hit("kick", 0)`; keyed voices with `note()`.
|
|
351
|
+
*/
|
|
352
|
+
export function sampler(
|
|
353
|
+
voices: Readonly<Record<string, string | SampleSpec>>,
|
|
354
|
+
options: SamplerOptions = {},
|
|
355
|
+
): SamplerSpec {
|
|
356
|
+
if (!isRecord(voices)) throw new DawgSdkError("sampler needs a voice map");
|
|
357
|
+
const names = Object.keys(voices);
|
|
358
|
+
if (names.length === 0 || names.length > 64)
|
|
359
|
+
throw new DawgSdkError("sampler needs 1..64 voices");
|
|
360
|
+
const mode = options.mode ?? "oneshot";
|
|
361
|
+
if (mode !== "oneshot" && mode !== "keyed")
|
|
362
|
+
throw new DawgSdkError('sampler mode must be "oneshot" or "keyed"');
|
|
363
|
+
const out: Record<string, SampleSpec> = {};
|
|
364
|
+
for (const name of names.sort()) {
|
|
365
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name) || name.length > 32)
|
|
366
|
+
throw new DawgSdkError(
|
|
367
|
+
`sampler voice "${name}" must be a short identifier`,
|
|
368
|
+
);
|
|
369
|
+
out[name] = sample(voices[name]!, name);
|
|
370
|
+
}
|
|
371
|
+
return Object.freeze({ kind: "sampler", voices: Object.freeze(out), mode });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* `count` equal slices of one file as voices `prefix0 … prefixN-1`, for
|
|
376
|
+
* chopped breaks: `sampler(slices("samples/break.wav", 8, "brk"))`, then
|
|
377
|
+
* `hit("brk3", 1.5)`.
|
|
378
|
+
*/
|
|
379
|
+
export function slices(
|
|
380
|
+
src: string | SampleSpec,
|
|
381
|
+
count: number,
|
|
382
|
+
prefix = "slice",
|
|
383
|
+
): Record<string, SampleSpec> {
|
|
384
|
+
if (!Number.isInteger(count) || count < 1 || count > 64)
|
|
385
|
+
throw new DawgSdkError("slices count must be an integer 1..64");
|
|
386
|
+
const base = sample(src, prefix);
|
|
387
|
+
const begin = base.begin ?? 0;
|
|
388
|
+
const end = base.end ?? 1;
|
|
389
|
+
const span = (end - begin) / count;
|
|
390
|
+
const voices: Record<string, SampleSpec> = {};
|
|
391
|
+
for (let index = 0; index < count; index += 1) {
|
|
392
|
+
voices[`${prefix}${index}`] = Object.freeze({
|
|
393
|
+
...base,
|
|
394
|
+
begin: round(begin + index * span),
|
|
395
|
+
end: round(index === count - 1 ? end : begin + (index + 1) * span),
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
return voices;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function sample(value: string | SampleSpec, name: string): SampleSpec {
|
|
402
|
+
const spec = typeof value === "string" ? { src: value } : value;
|
|
403
|
+
if (!isRecord(spec) || typeof spec.src !== "string" || spec.src.length === 0)
|
|
404
|
+
throw new DawgSdkError(`sampler voice ${name} needs a src path`);
|
|
405
|
+
const out: {
|
|
406
|
+
src: string;
|
|
407
|
+
root?: number;
|
|
408
|
+
begin?: number;
|
|
409
|
+
end?: number;
|
|
410
|
+
gain?: number;
|
|
411
|
+
speed?: number;
|
|
412
|
+
loop?: boolean;
|
|
413
|
+
choke?: string;
|
|
414
|
+
} = { src: spec.src };
|
|
415
|
+
if (spec.root !== undefined) out.root = midi(spec.root);
|
|
416
|
+
if (spec.begin !== undefined) out.begin = unit(spec.begin, `${name} begin`);
|
|
417
|
+
if (spec.end !== undefined) out.end = unit(spec.end, `${name} end`);
|
|
418
|
+
if (spec.gain !== undefined) out.gain = finite(spec.gain, `${name} gain`);
|
|
419
|
+
if (spec.speed !== undefined) out.speed = finite(spec.speed, `${name} speed`);
|
|
420
|
+
if (spec.loop !== undefined) {
|
|
421
|
+
if (typeof spec.loop !== "boolean")
|
|
422
|
+
throw new DawgSdkError(`${name} loop must be boolean`);
|
|
423
|
+
out.loop = spec.loop;
|
|
424
|
+
}
|
|
425
|
+
if (spec.choke !== undefined) {
|
|
426
|
+
if (typeof spec.choke !== "string")
|
|
427
|
+
throw new DawgSdkError(`${name} choke must be a group name`);
|
|
428
|
+
out.choke = spec.choke;
|
|
429
|
+
}
|
|
430
|
+
return Object.freeze(out);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// Tracks
|
|
435
|
+
|
|
436
|
+
/** `[beat, value]` control point for `automation`. */
|
|
437
|
+
export type Point = readonly [beat: number, value: number];
|
|
438
|
+
|
|
439
|
+
/** Automation lanes in beats. Each lane replaces the stored lane entirely. */
|
|
440
|
+
export type AutomationInput = Readonly<{
|
|
441
|
+
/** 0..1 */
|
|
442
|
+
volume?: readonly Point[];
|
|
443
|
+
/** -1..1 */
|
|
444
|
+
pan?: readonly Point[];
|
|
445
|
+
/** Filter cutoff in Hz, 20..20000 (needs `filter`). */
|
|
446
|
+
filter?: readonly Point[];
|
|
447
|
+
/** 0..1 (needs `filter`). */
|
|
448
|
+
resonance?: readonly Point[];
|
|
449
|
+
/** 0..0.9 (needs `delay`). */
|
|
450
|
+
delayFeedback?: readonly Point[];
|
|
451
|
+
/** 0..1 (needs `delay`). */
|
|
452
|
+
delayMix?: readonly Point[];
|
|
453
|
+
}>;
|
|
454
|
+
|
|
455
|
+
/** Input to `track()`. Omitted fields keep dawg's defaults. */
|
|
456
|
+
export type TrackInput = Readonly<{
|
|
457
|
+
/** Stable id dawg assigned; defaults to the slug of `name`. Keep it when editing. */
|
|
458
|
+
id?: string;
|
|
459
|
+
/** Shown in the header; its slug names `tracks/<slug>/`. */
|
|
460
|
+
name: string;
|
|
461
|
+
/**
|
|
462
|
+
* Synth voice (`sine`, `piano`, `pluck`, `bass`, `saw`, `square`,
|
|
463
|
+
* `triangle`), `kit` for drums, or `sampler(...)`. Default `sine`.
|
|
464
|
+
*/
|
|
465
|
+
instrument?: string | SamplerSpec;
|
|
466
|
+
muted?: boolean;
|
|
467
|
+
/** When any track is soloed only soloed tracks play. */
|
|
468
|
+
solo?: boolean;
|
|
469
|
+
/** 0..1, default 1. */
|
|
470
|
+
volume?: number;
|
|
471
|
+
/** -1 (left) .. 1 (right), default 0. */
|
|
472
|
+
pan?: number;
|
|
473
|
+
/** Low-pass filter; `null` or omitted means none. */
|
|
474
|
+
filter?: Readonly<{ cutoff: number; resonance?: number }> | null;
|
|
475
|
+
/** Tempo-synced ping-pong delay send in beats. */
|
|
476
|
+
delay?: Readonly<{ beats: number; feedback?: number; mix?: number }> | null;
|
|
477
|
+
/** Stereo reverb send. */
|
|
478
|
+
reverb?: Readonly<{ mix: number; size?: number }> | null;
|
|
479
|
+
automation?: AutomationInput;
|
|
480
|
+
/** `note()`/`seq()` for pitched tracks, `hit()`/`hits()` for kits and one-shot samplers. */
|
|
481
|
+
notes?: readonly (NoteSpec | HitSpec)[];
|
|
482
|
+
}>;
|
|
483
|
+
|
|
484
|
+
/** Frozen track built by `track()`; `song()` consumes it. Beats, not ticks. */
|
|
485
|
+
export type TrackSpec = Readonly<{
|
|
486
|
+
kind: "track";
|
|
487
|
+
id: string;
|
|
488
|
+
name: string;
|
|
489
|
+
slug: string;
|
|
490
|
+
instrument: string;
|
|
491
|
+
muted: boolean;
|
|
492
|
+
solo: boolean;
|
|
493
|
+
volume: number;
|
|
494
|
+
pan: number;
|
|
495
|
+
filter: Readonly<{ cutoff: number; resonance: number }> | null;
|
|
496
|
+
delay: Readonly<{ beats: number; feedback: number; mix: number }> | null;
|
|
497
|
+
reverb: Readonly<{ mix: number; size: number }> | null;
|
|
498
|
+
sampler: SamplerSpec | null;
|
|
499
|
+
automation: Readonly<Required<AutomationInput>>;
|
|
500
|
+
/** Every hit resolved to its pitch slot. */
|
|
501
|
+
notes: readonly NoteSpec[];
|
|
502
|
+
}>;
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Build a track. Hits are resolved to pitches here: GM numbers on a kit,
|
|
506
|
+
* voice slots (36, 37, … in voice-name order) on a one-shot sampler.
|
|
507
|
+
* Sample paths without a `tracks/` prefix are made project-relative under
|
|
508
|
+
* this track's `tracks/<slug>/`.
|
|
509
|
+
*/
|
|
510
|
+
export function track(input: TrackInput): TrackSpec {
|
|
511
|
+
if (!isRecord(input)) throw new DawgSdkError("track() needs an object");
|
|
512
|
+
if (typeof input.name !== "string" || input.name.trim().length === 0)
|
|
513
|
+
throw new DawgSdkError("track name must be a non-empty string");
|
|
514
|
+
if (input.name.length > 96)
|
|
515
|
+
throw new DawgSdkError("track name must be at most 96 characters");
|
|
516
|
+
const name = input.name;
|
|
517
|
+
const slug = slugify(name);
|
|
518
|
+
const id = input.id ?? slug;
|
|
519
|
+
if (typeof id !== "string" || id.length === 0 || id.length > 64)
|
|
520
|
+
throw new DawgSdkError(`track ${name}: id must be 1..64 characters`);
|
|
521
|
+
const rawInstrument = input.instrument ?? "sine";
|
|
522
|
+
const samplerSpec =
|
|
523
|
+
isRecord(rawInstrument) && rawInstrument.kind === "sampler"
|
|
524
|
+
? localizeSampler(rawInstrument as SamplerSpec, slug)
|
|
525
|
+
: null;
|
|
526
|
+
const instrument = samplerSpec
|
|
527
|
+
? SAMPLER_INSTRUMENT
|
|
528
|
+
: typeof rawInstrument === "string"
|
|
529
|
+
? rawInstrument
|
|
530
|
+
: undefined;
|
|
531
|
+
if (
|
|
532
|
+
instrument === undefined ||
|
|
533
|
+
instrument.length === 0 ||
|
|
534
|
+
instrument.length > 64
|
|
535
|
+
)
|
|
536
|
+
throw new DawgSdkError(
|
|
537
|
+
`track ${name}: instrument must be a voice name, "kit" or sampler(...)`,
|
|
538
|
+
);
|
|
539
|
+
if (instrument === SAMPLER_INSTRUMENT && !samplerSpec)
|
|
540
|
+
throw new DawgSdkError(
|
|
541
|
+
`track ${name}: use instrument: sampler({...}) for a sampler track`,
|
|
542
|
+
);
|
|
543
|
+
const slots = samplerSpec ? voiceSlots(samplerSpec) : undefined;
|
|
544
|
+
const kit = KIT_INSTRUMENTS.includes(instrument.trim().toLowerCase());
|
|
545
|
+
const notes = (input.notes ?? []).map((item, index) => {
|
|
546
|
+
if (!isRecord(item) || (item.kind !== "note" && item.kind !== "hit"))
|
|
547
|
+
throw new DawgSdkError(
|
|
548
|
+
`track ${name}: notes[${index}] must come from note(), seq(), hit() or hits()`,
|
|
549
|
+
);
|
|
550
|
+
if (item.kind === "note") return item as NoteSpec;
|
|
551
|
+
const spec = item as HitSpec;
|
|
552
|
+
const pitch = kit
|
|
553
|
+
? DRUM_PITCHES[
|
|
554
|
+
DRUM_ALIASES[spec.voice.trim().toLowerCase()] ??
|
|
555
|
+
(undefined as unknown as DrumVoice)
|
|
556
|
+
]
|
|
557
|
+
: slots?.get(spec.voice);
|
|
558
|
+
if (pitch === undefined)
|
|
559
|
+
throw new DawgSdkError(
|
|
560
|
+
kit
|
|
561
|
+
? `track ${name}: unknown drum voice "${spec.voice}" (kick snare clap rim tom hat openhat)`
|
|
562
|
+
: slots
|
|
563
|
+
? `track ${name}: unknown sampler voice "${spec.voice}" (${[...slots.keys()].join(" ")})`
|
|
564
|
+
: `track ${name}: hit("${spec.voice}") needs instrument "kit" or sampler(...)`,
|
|
565
|
+
);
|
|
566
|
+
return Object.freeze({
|
|
567
|
+
kind: "note" as const,
|
|
568
|
+
pitch,
|
|
569
|
+
start: spec.start,
|
|
570
|
+
length: spec.length,
|
|
571
|
+
velocity: spec.velocity,
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
if (notes.length > 4096)
|
|
575
|
+
throw new DawgSdkError(`track ${name}: at most 4096 notes`);
|
|
576
|
+
const automation = input.automation ?? {};
|
|
577
|
+
if (!isRecord(automation))
|
|
578
|
+
throw new DawgSdkError(`track ${name}: automation must be an object`);
|
|
579
|
+
const lane = (key: keyof AutomationInput): readonly Point[] => {
|
|
580
|
+
const points = (automation as Record<string, unknown>)[key];
|
|
581
|
+
if (points === undefined) return Object.freeze([]);
|
|
582
|
+
if (!Array.isArray(points) || points.length > 256)
|
|
583
|
+
throw new DawgSdkError(
|
|
584
|
+
`track ${name}: automation.${key} must be an array of at most 256 [beat, value] points`,
|
|
585
|
+
);
|
|
586
|
+
return Object.freeze(
|
|
587
|
+
points.map((point: unknown, index: number): Point => {
|
|
588
|
+
if (!Array.isArray(point) || point.length !== 2)
|
|
589
|
+
throw new DawgSdkError(
|
|
590
|
+
`track ${name}: automation.${key}[${index}] must be [beat, value]`,
|
|
591
|
+
);
|
|
592
|
+
return Object.freeze([
|
|
593
|
+
beat(point[0], `automation.${key}[${index}] beat`),
|
|
594
|
+
finite(point[1], `automation.${key}[${index}] value`),
|
|
595
|
+
] as const);
|
|
596
|
+
}),
|
|
597
|
+
);
|
|
598
|
+
};
|
|
599
|
+
for (const key of Object.keys(automation))
|
|
600
|
+
if (!AUTOMATION_KEYS.includes(key as keyof AutomationInput))
|
|
601
|
+
throw new DawgSdkError(
|
|
602
|
+
`track ${name}: unknown automation lane "${key}" (${AUTOMATION_KEYS.join(" ")})`,
|
|
603
|
+
);
|
|
604
|
+
const filter =
|
|
605
|
+
input.filter === undefined || input.filter === null
|
|
606
|
+
? null
|
|
607
|
+
: Object.freeze({
|
|
608
|
+
cutoff: finite(input.filter.cutoff, `${name} filter.cutoff`),
|
|
609
|
+
resonance: finite(
|
|
610
|
+
input.filter.resonance ?? 0,
|
|
611
|
+
`${name} filter.resonance`,
|
|
612
|
+
),
|
|
613
|
+
});
|
|
614
|
+
const delay =
|
|
615
|
+
input.delay === undefined || input.delay === null
|
|
616
|
+
? null
|
|
617
|
+
: Object.freeze({
|
|
618
|
+
beats: finite(input.delay.beats, `${name} delay.beats`),
|
|
619
|
+
feedback: finite(
|
|
620
|
+
input.delay.feedback ?? 0.3,
|
|
621
|
+
`${name} delay.feedback`,
|
|
622
|
+
),
|
|
623
|
+
mix: finite(input.delay.mix ?? 0.35, `${name} delay.mix`),
|
|
624
|
+
});
|
|
625
|
+
const reverb =
|
|
626
|
+
input.reverb === undefined || input.reverb === null
|
|
627
|
+
? null
|
|
628
|
+
: Object.freeze({
|
|
629
|
+
mix: finite(input.reverb.mix, `${name} reverb.mix`),
|
|
630
|
+
size: finite(input.reverb.size ?? 0.5, `${name} reverb.size`),
|
|
631
|
+
});
|
|
632
|
+
return Object.freeze({
|
|
633
|
+
kind: "track",
|
|
634
|
+
id,
|
|
635
|
+
name,
|
|
636
|
+
slug,
|
|
637
|
+
instrument,
|
|
638
|
+
muted: bool(input.muted ?? false, `${name} muted`),
|
|
639
|
+
solo: bool(input.solo ?? false, `${name} solo`),
|
|
640
|
+
volume: finite(input.volume ?? 1, `${name} volume`),
|
|
641
|
+
pan: finite(input.pan ?? 0, `${name} pan`),
|
|
642
|
+
filter,
|
|
643
|
+
delay,
|
|
644
|
+
reverb,
|
|
645
|
+
sampler: samplerSpec,
|
|
646
|
+
automation: Object.freeze({
|
|
647
|
+
volume: lane("volume"),
|
|
648
|
+
pan: lane("pan"),
|
|
649
|
+
filter: lane("filter"),
|
|
650
|
+
resonance: lane("resonance"),
|
|
651
|
+
delayFeedback: lane("delayFeedback"),
|
|
652
|
+
delayMix: lane("delayMix"),
|
|
653
|
+
}),
|
|
654
|
+
notes: Object.freeze(notes),
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const AUTOMATION_KEYS: readonly (keyof AutomationInput)[] = Object.freeze([
|
|
659
|
+
"volume",
|
|
660
|
+
"pan",
|
|
661
|
+
"filter",
|
|
662
|
+
"resonance",
|
|
663
|
+
"delayFeedback",
|
|
664
|
+
"delayMix",
|
|
665
|
+
]);
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Directory name for a track: lowercase, spaces and runs of punctuation
|
|
669
|
+
* become one `-` (`"Keys 2"` → `keys-2`); empty input becomes `track`.
|
|
670
|
+
* Same rule as dawg's `trackSlug`, copied so this file stays standalone.
|
|
671
|
+
*/
|
|
672
|
+
export function slugify(name: string): string {
|
|
673
|
+
const slug = name
|
|
674
|
+
.normalize("NFKD")
|
|
675
|
+
.replace(/\p{M}+/gu, "")
|
|
676
|
+
.toLowerCase()
|
|
677
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
678
|
+
.replace(/^-+|-+$/g, "")
|
|
679
|
+
.slice(0, 64)
|
|
680
|
+
.replace(/-+$/g, "");
|
|
681
|
+
return slug.length > 0 ? slug : "track";
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Pitch slot for each one-shot voice, in voice-name order from 36. */
|
|
685
|
+
export function voiceSlots(spec: SamplerSpec): ReadonlyMap<string, number> {
|
|
686
|
+
const slots = new Map<string, number>();
|
|
687
|
+
if (spec.mode !== "oneshot") return slots;
|
|
688
|
+
Object.keys(spec.voices)
|
|
689
|
+
.sort()
|
|
690
|
+
.forEach((voice, index) => slots.set(voice, SAMPLER_FIRST_SLOT + index));
|
|
691
|
+
return slots;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function localizeSampler(spec: SamplerSpec, slug: string): SamplerSpec {
|
|
695
|
+
const voices: Record<string, SampleSpec> = {};
|
|
696
|
+
for (const [name, voice] of Object.entries(spec.voices)) {
|
|
697
|
+
const src = voice.src.replace(/^\.\//, "");
|
|
698
|
+
voices[name] = Object.freeze({
|
|
699
|
+
...voice,
|
|
700
|
+
src: src.startsWith("tracks/") ? src : `tracks/${slug}/${src}`,
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
return Object.freeze({ ...spec, voices: Object.freeze(voices) });
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ---------------------------------------------------------------------------
|
|
707
|
+
// Song
|
|
708
|
+
|
|
709
|
+
/** Input to `song()`. */
|
|
710
|
+
export type SongInput = Readonly<{
|
|
711
|
+
/** BPM 20..300, default 120. */
|
|
712
|
+
tempo?: number;
|
|
713
|
+
/** `[beatsPerBar, noteValue]` or just `beatsPerBar`; default `[4, 4]`. Only the numerator is stored. */
|
|
714
|
+
meter?: readonly [number, number] | number;
|
|
715
|
+
/** Loop length in bars, 1..256, default 4. */
|
|
716
|
+
bars?: number;
|
|
717
|
+
/** Free text such as `"A minor"`, or null. */
|
|
718
|
+
key?: string | null;
|
|
719
|
+
/** Integer ticks per beat, default 480. Leave it alone unless you know why. */
|
|
720
|
+
ticksPerBeat?: number;
|
|
721
|
+
/** Tracks in score order; each from `track()`. */
|
|
722
|
+
tracks: readonly TrackSpec[];
|
|
723
|
+
}>;
|
|
724
|
+
|
|
725
|
+
/** A stored note: integer ticks. */
|
|
726
|
+
export type ScoreNote = Readonly<{
|
|
727
|
+
id: string;
|
|
728
|
+
trackId: string;
|
|
729
|
+
startTick: number;
|
|
730
|
+
durationTicks: number;
|
|
731
|
+
pitch: number;
|
|
732
|
+
velocity: number;
|
|
733
|
+
}>;
|
|
734
|
+
|
|
735
|
+
/** A stored automation point: integer tick. */
|
|
736
|
+
export type ScorePoint = Readonly<{ tick: number; value: number }>;
|
|
737
|
+
|
|
738
|
+
/** A stored sample reference (project-relative `src`, MIDI `root`). */
|
|
739
|
+
export type ScoreSampleRef = Readonly<{
|
|
740
|
+
src: string;
|
|
741
|
+
sha256?: string;
|
|
742
|
+
root?: number;
|
|
743
|
+
begin?: number;
|
|
744
|
+
end?: number;
|
|
745
|
+
gain?: number;
|
|
746
|
+
speed?: number;
|
|
747
|
+
loop?: boolean;
|
|
748
|
+
choke?: string;
|
|
749
|
+
}>;
|
|
750
|
+
|
|
751
|
+
/** A stored track; optional fields are present only when set. */
|
|
752
|
+
export type ScoreTrack = Readonly<{
|
|
753
|
+
id: string;
|
|
754
|
+
name: string;
|
|
755
|
+
instrument: string;
|
|
756
|
+
muted: boolean;
|
|
757
|
+
volume: number;
|
|
758
|
+
pan: number;
|
|
759
|
+
volumeAutomation: readonly ScorePoint[];
|
|
760
|
+
panAutomation: readonly ScorePoint[];
|
|
761
|
+
solo?: boolean;
|
|
762
|
+
filter?: Readonly<{ cutoff: number; resonance: number }>;
|
|
763
|
+
delay?: Readonly<{ beats: number; feedback: number; mix: number }>;
|
|
764
|
+
filterAutomation?: readonly ScorePoint[];
|
|
765
|
+
resonanceAutomation?: readonly ScorePoint[];
|
|
766
|
+
delayFeedbackAutomation?: readonly ScorePoint[];
|
|
767
|
+
delayMixAutomation?: readonly ScorePoint[];
|
|
768
|
+
reverb?: Readonly<{ mix: number; size: number }>;
|
|
769
|
+
sampler?: Readonly<{
|
|
770
|
+
voices: Readonly<Record<string, ScoreSampleRef>>;
|
|
771
|
+
mode: "oneshot" | "keyed";
|
|
772
|
+
}>;
|
|
773
|
+
}>;
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* What `song()` returns and `song.ts` default-exports: a `track.loop/v1`
|
|
777
|
+
* document in integer ticks, ready for dawg to validate and diff against
|
|
778
|
+
* the session.
|
|
779
|
+
*/
|
|
780
|
+
export type Song = Readonly<{
|
|
781
|
+
format: "track.loop/v1";
|
|
782
|
+
version: 1;
|
|
783
|
+
tempoBpm: number;
|
|
784
|
+
beatsPerBar: number;
|
|
785
|
+
bars: number;
|
|
786
|
+
ticksPerBeat: number;
|
|
787
|
+
key: string | null;
|
|
788
|
+
tracks: readonly ScoreTrack[];
|
|
789
|
+
notes: readonly ScoreNote[];
|
|
790
|
+
}>;
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Assemble the song. Beats become ticks (`Math.round(beat * ticksPerBeat)`,
|
|
794
|
+
* lengths at least one tick), and every note gets a deterministic id from
|
|
795
|
+
* its track and content, so two evaluations of the same files agree.
|
|
796
|
+
*/
|
|
797
|
+
export function song(input: SongInput): Song {
|
|
798
|
+
if (!isRecord(input)) throw new DawgSdkError("song() needs an object");
|
|
799
|
+
const tempoBpm = finite(input.tempo ?? 120, "song tempo");
|
|
800
|
+
const meter = input.meter ?? 4;
|
|
801
|
+
const beatsPerBar = Array.isArray(meter)
|
|
802
|
+
? finite(meter[0], "song meter[0]")
|
|
803
|
+
: finite(meter as number, "song meter");
|
|
804
|
+
if (Array.isArray(meter)) finite(meter[1], "song meter[1]");
|
|
805
|
+
const bars = finite(input.bars ?? 4, "song bars");
|
|
806
|
+
const ticksPerBeat = input.ticksPerBeat ?? DEFAULT_TICKS_PER_BEAT;
|
|
807
|
+
if (
|
|
808
|
+
!Number.isInteger(ticksPerBeat) ||
|
|
809
|
+
ticksPerBeat < 1 ||
|
|
810
|
+
ticksPerBeat > 4096
|
|
811
|
+
)
|
|
812
|
+
throw new DawgSdkError("song ticksPerBeat must be an integer 1..4096");
|
|
813
|
+
const key = input.key ?? null;
|
|
814
|
+
if (key !== null && typeof key !== "string")
|
|
815
|
+
throw new DawgSdkError("song key must be a string or null");
|
|
816
|
+
if (!Array.isArray(input.tracks))
|
|
817
|
+
throw new DawgSdkError("song tracks must be an array of track()");
|
|
818
|
+
if (input.tracks.length > 64)
|
|
819
|
+
throw new DawgSdkError("song has over 64 tracks");
|
|
820
|
+
const ticks = (beats: number) => Math.round(beats * ticksPerBeat);
|
|
821
|
+
const points = (lane: readonly Point[]): readonly ScorePoint[] =>
|
|
822
|
+
Object.freeze(
|
|
823
|
+
lane.map(([at, value]) => Object.freeze({ tick: ticks(at), value })),
|
|
824
|
+
);
|
|
825
|
+
const seen = new Set<string>();
|
|
826
|
+
const tracks: ScoreTrack[] = [];
|
|
827
|
+
const notes: ScoreNote[] = [];
|
|
828
|
+
input.tracks.forEach((spec, index) => {
|
|
829
|
+
if (!isRecord(spec) || spec.kind !== "track")
|
|
830
|
+
throw new DawgSdkError(`song tracks[${index}] must come from track()`);
|
|
831
|
+
const t = spec as TrackSpec;
|
|
832
|
+
if (seen.has(t.id))
|
|
833
|
+
throw new DawgSdkError(`song has two tracks with id "${t.id}"`);
|
|
834
|
+
seen.add(t.id);
|
|
835
|
+
const filterAutomation = points(t.automation.filter);
|
|
836
|
+
const resonanceAutomation = points(t.automation.resonance);
|
|
837
|
+
const delayFeedbackAutomation = points(t.automation.delayFeedback);
|
|
838
|
+
const delayMixAutomation = points(t.automation.delayMix);
|
|
839
|
+
const stored: Record<string, unknown> = {
|
|
840
|
+
id: t.id,
|
|
841
|
+
name: t.name,
|
|
842
|
+
instrument: t.instrument,
|
|
843
|
+
muted: t.muted,
|
|
844
|
+
volume: t.volume,
|
|
845
|
+
pan: t.pan,
|
|
846
|
+
volumeAutomation: points(t.automation.volume),
|
|
847
|
+
panAutomation: points(t.automation.pan),
|
|
848
|
+
};
|
|
849
|
+
if (t.solo) stored.solo = true;
|
|
850
|
+
if (t.filter) stored.filter = t.filter;
|
|
851
|
+
if (t.delay) stored.delay = t.delay;
|
|
852
|
+
if (filterAutomation.length > 0) stored.filterAutomation = filterAutomation;
|
|
853
|
+
if (resonanceAutomation.length > 0)
|
|
854
|
+
stored.resonanceAutomation = resonanceAutomation;
|
|
855
|
+
if (delayFeedbackAutomation.length > 0)
|
|
856
|
+
stored.delayFeedbackAutomation = delayFeedbackAutomation;
|
|
857
|
+
if (delayMixAutomation.length > 0)
|
|
858
|
+
stored.delayMixAutomation = delayMixAutomation;
|
|
859
|
+
if (t.reverb) stored.reverb = t.reverb;
|
|
860
|
+
if (t.sampler)
|
|
861
|
+
stored.sampler = Object.freeze({
|
|
862
|
+
voices: t.sampler.voices,
|
|
863
|
+
mode: t.sampler.mode,
|
|
864
|
+
});
|
|
865
|
+
tracks.push(Object.freeze(stored) as ScoreTrack);
|
|
866
|
+
const ids = new Set<string>();
|
|
867
|
+
for (const n of t.notes) {
|
|
868
|
+
const startTick = ticks(n.start);
|
|
869
|
+
const durationTicks = Math.max(1, ticks(n.length));
|
|
870
|
+
let id = "";
|
|
871
|
+
for (let occurrence = 0; ; occurrence += 1) {
|
|
872
|
+
id = `n-${hash64(`${t.id}|${n.pitch}|${startTick}|${durationTicks}|${n.velocity}|${occurrence}`)}`;
|
|
873
|
+
if (!ids.has(id)) break;
|
|
874
|
+
}
|
|
875
|
+
ids.add(id);
|
|
876
|
+
notes.push(
|
|
877
|
+
Object.freeze({
|
|
878
|
+
id,
|
|
879
|
+
trackId: t.id,
|
|
880
|
+
startTick,
|
|
881
|
+
durationTicks,
|
|
882
|
+
pitch: n.pitch,
|
|
883
|
+
velocity: n.velocity,
|
|
884
|
+
}),
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
return Object.freeze({
|
|
889
|
+
format: "track.loop/v1",
|
|
890
|
+
version: 1,
|
|
891
|
+
tempoBpm,
|
|
892
|
+
beatsPerBar,
|
|
893
|
+
bars,
|
|
894
|
+
ticksPerBeat,
|
|
895
|
+
key,
|
|
896
|
+
tracks: Object.freeze(tracks),
|
|
897
|
+
notes: Object.freeze(notes),
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// ---------------------------------------------------------------------------
|
|
902
|
+
// Internals
|
|
903
|
+
|
|
904
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
905
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function finite(value: unknown, label: string): number {
|
|
909
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
910
|
+
throw new DawgSdkError(`${label} must be a finite number`);
|
|
911
|
+
return value;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function beat(value: unknown, label: string): number {
|
|
915
|
+
const number = finite(value, label);
|
|
916
|
+
if (number < 0) throw new DawgSdkError(`${label} must be ≥ 0 beats`);
|
|
917
|
+
return number;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function positive(value: unknown, label: string): number {
|
|
921
|
+
const number = finite(value, label);
|
|
922
|
+
if (number <= 0) throw new DawgSdkError(`${label} must be > 0`);
|
|
923
|
+
return number;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function unit(value: unknown, label: string): number {
|
|
927
|
+
const number = finite(value, label);
|
|
928
|
+
if (number < 0 || number > 1)
|
|
929
|
+
throw new DawgSdkError(`${label} must be between 0 and 1`);
|
|
930
|
+
return number;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function bool(value: unknown, label: string): boolean {
|
|
934
|
+
if (typeof value !== "boolean")
|
|
935
|
+
throw new DawgSdkError(`${label} must be true or false`);
|
|
936
|
+
return value;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** Rounds away floating-point noise from repeated addition (1e-9 beats). */
|
|
940
|
+
function round(value: number): number {
|
|
941
|
+
return Math.round(value * 1e9) / 1e9;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** 64-bit FNV-1a over UTF-16 code units as 16 hex characters (two 32-bit lanes). */
|
|
945
|
+
function hash64(text: string): string {
|
|
946
|
+
let a = 0x811c9dc5;
|
|
947
|
+
let b = 0xcbf29ce4;
|
|
948
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
949
|
+
const code = text.charCodeAt(index);
|
|
950
|
+
a = Math.imul(a ^ code, 0x01000193) >>> 0;
|
|
951
|
+
b = Math.imul(b ^ ((code * 31 + index) & 0xffff), 0x01000193) >>> 0;
|
|
952
|
+
}
|
|
953
|
+
return a.toString(16).padStart(8, "0") + b.toString(16).padStart(8, "0");
|
|
954
|
+
}
|