@hraness/dawg 0.5.0 → 0.6.1
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 +63 -0
- package/DAWG.md +592 -49
- package/README.md +4 -4
- package/core/chords.ts +492 -4
- package/core/diff.ts +9 -0
- package/core/expression.ts +143 -6
- package/core/fx.ts +689 -2
- package/core/granular.ts +619 -0
- package/core/instruments.ts +442 -0
- package/core/keys.ts +955 -0
- package/core/resonators.ts +862 -0
- package/core/score.ts +529 -8
- package/core/sdk/eval-child.ts +2 -0
- package/core/sdk/print.ts +247 -16
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +2417 -40
- package/core/sections.ts +44 -10
- package/core/strings.ts +1080 -0
- package/core/winds.ts +652 -0
- package/guides/automation.md +1 -0
- package/guides/chords.md +3 -1
- package/guides/effects.md +5 -2
- package/guides/media.md +1 -1
- package/guides/performance.md +3 -0
- package/guides/resample.md +26 -0
- package/guides/sounds.md +7 -2
- package/guides/tempo.md +1 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +13 -0
- package/src/agent/chord-tools.ts +153 -1
- package/src/agent/expression-tools.ts +91 -0
- package/src/agent/granular-tools.ts +138 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +42 -2
- package/src/agent/preview-tool.ts +7 -1
- package/src/agent/resample-tool.ts +131 -0
- package/src/agent/tools.ts +726 -17
- package/src/agent/xcb-agent.ts +13 -0
- package/src/audio/arrange.ts +42 -3
- package/src/audio/dsp/bank.ts +233 -0
- package/src/audio/dsp/envelope.ts +161 -0
- package/src/audio/dsp/fft.ts +6 -0
- package/src/audio/dsp/filters.ts +57 -0
- package/src/audio/dsp/interp.ts +112 -0
- package/src/audio/dsp/modal.ts +684 -0
- package/src/audio/dsp/onset.ts +197 -0
- package/src/audio/dsp/oversample.ts +202 -0
- package/src/audio/dsp/rng.ts +37 -0
- package/src/audio/dsp/shape.ts +81 -0
- package/src/audio/dsp/shift.ts +256 -0
- package/src/audio/dsp/stft.ts +73 -0
- package/src/audio/dsp/window.ts +77 -0
- package/src/audio/effects/chain.ts +23 -3
- package/src/audio/effects/common.ts +14 -0
- package/src/audio/effects/convolution.ts +106 -1
- package/src/audio/effects/gaze.ts +354 -0
- package/src/audio/effects/rig/cab.ts +99 -0
- package/src/audio/effects/rig/filters.ts +152 -0
- package/src/audio/effects/rig/gate.ts +39 -0
- package/src/audio/effects/rig/head.ts +487 -0
- package/src/audio/effects/rig/index.ts +170 -0
- package/src/audio/effects/rig/section.ts +78 -0
- package/src/audio/effects/rig/stomp.ts +238 -0
- package/src/audio/engine.ts +56 -6
- package/src/audio/fit.ts +447 -0
- package/src/audio/granular.ts +995 -0
- package/src/audio/instrument-check.ts +137 -0
- package/src/audio/instruments.ts +140 -0
- package/src/audio/keys/dsp.ts +323 -0
- package/src/audio/keys/electric.ts +420 -0
- package/src/audio/keys/engine.ts +485 -0
- package/src/audio/keys/organ.ts +1335 -0
- package/src/audio/keys/piano.ts +476 -0
- package/src/audio/keys/sympathetic.ts +127 -0
- package/src/audio/live-worker.ts +56 -0
- package/src/audio/live.ts +361 -15
- package/src/audio/loudness.ts +65 -20
- package/src/audio/preview.ts +17 -1
- package/src/audio/resample.ts +285 -0
- package/src/audio/resonators.ts +295 -0
- package/src/audio/sampler.ts +328 -35
- package/src/audio/samples.ts +56 -8
- package/src/audio/strings/body.ts +263 -0
- package/src/audio/strings/bow.ts +656 -0
- package/src/audio/strings/engine.ts +615 -0
- package/src/audio/strings/loop.ts +119 -0
- package/src/audio/strings/measure.test-helpers.ts +200 -0
- package/src/audio/strings/pluck.ts +354 -0
- package/src/audio/warp.ts +61 -0
- package/src/audio/wav.ts +191 -19
- package/src/audio/winds/engine.ts +302 -0
- package/src/audio/winds/filters.ts +153 -0
- package/src/audio/winds/pitch.ts +104 -0
- package/src/audio/winds/trim.ts +80 -0
- package/src/audio/winds/trims.ts +917 -0
- package/src/audio/winds/voice.ts +479 -0
- package/src/commands/expression.ts +111 -23
- package/src/commands/fit.ts +135 -0
- package/src/commands/fx.ts +35 -1
- package/src/commands/granular.ts +432 -0
- package/src/commands/help.ts +159 -5
- package/src/commands/keys.ts +629 -0
- package/src/commands/modal.ts +310 -0
- package/src/commands/resample.ts +281 -0
- package/src/commands/rig.ts +264 -0
- package/src/commands/sample.ts +52 -2
- package/src/commands/shift.ts +119 -0
- package/src/commands/string.ts +201 -0
- package/src/commands/strum.ts +473 -0
- package/src/commands/time.ts +4 -1
- package/src/commands/wind.ts +244 -0
- package/src/main.ts +339 -15
- package/src/project/check.ts +17 -0
- package/src/render.ts +49 -4
- package/src/session/presence.ts +16 -3
- package/src/tui/audition.ts +2 -2
- package/src/tui/granular-menu.ts +278 -0
- package/src/tui/menu.ts +934 -9
- package/src/tui/modal-menu.ts +145 -0
- package/src/tui/performance-menu.ts +42 -0
- package/src/tui/play-chords.ts +104 -3
- package/src/tui/play-mode.ts +1 -0
- package/src/tui/play-session.ts +70 -2
- package/src/tui/wind-menu.ts +144 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `modal` prompt command (0.6): modal percussion on the focused track
|
|
3
|
+
* (core/resonators.ts). The menu and the `set_modal` agent tool build the
|
|
4
|
+
* same edits.
|
|
5
|
+
*
|
|
6
|
+
* modal show the track's preset and overrides
|
|
7
|
+
* modal <preset> play a preset: marimba vibes xylophone
|
|
8
|
+
* glock celesta chimes kalimba mbira
|
|
9
|
+
* steelpan bowl gong timpani (and the
|
|
10
|
+
* aliases vibraphone glockenspiel
|
|
11
|
+
* tubular thumbpiano gongageng)
|
|
12
|
+
* modal preset <name> the same, spelled out
|
|
13
|
+
* modal mallet <yarn|cord|rubber|plastic|brass>
|
|
14
|
+
* modal <param> <value> [<param> <value>…] hardness 0.8, ring 3 …
|
|
15
|
+
* modal <param> off back to the preset's value
|
|
16
|
+
* modal reset clear overrides (keep the preset)
|
|
17
|
+
* modal off back to the legacy marimba voice
|
|
18
|
+
* modal list | presets modal the presets with one-line characters
|
|
19
|
+
*
|
|
20
|
+
* Any edit turns the track into a modal track (`instrument "modal"`); the
|
|
21
|
+
* legacy `marimba` tone is untouched until then. One `updateTrack` revision
|
|
22
|
+
* and one undo step per command.
|
|
23
|
+
*/
|
|
24
|
+
import { FxValidationError } from "../../core/params.ts";
|
|
25
|
+
import {
|
|
26
|
+
DEFAULT_MODAL_PRESET,
|
|
27
|
+
GAMELAN_PRESETS,
|
|
28
|
+
MODAL_INSTRUMENT,
|
|
29
|
+
MODAL_PARAMS,
|
|
30
|
+
MODAL_PRESETS,
|
|
31
|
+
MODAL_PRESET_NAMES,
|
|
32
|
+
MODAL_SIMPLE,
|
|
33
|
+
modalParamName,
|
|
34
|
+
modalPresetFor,
|
|
35
|
+
modalSettings,
|
|
36
|
+
normalizeModal,
|
|
37
|
+
type TrackModal,
|
|
38
|
+
} from "../../core/resonators.ts";
|
|
39
|
+
import {
|
|
40
|
+
ScoreValidationError,
|
|
41
|
+
updateTrack,
|
|
42
|
+
type TrackScore,
|
|
43
|
+
} from "../../core/score.ts";
|
|
44
|
+
import { parseParamValue } from "./fx.ts";
|
|
45
|
+
|
|
46
|
+
export type ModalCommand =
|
|
47
|
+
| { type: "modal-show" }
|
|
48
|
+
| { type: "modal-list" }
|
|
49
|
+
| { type: "modal-reset" }
|
|
50
|
+
| { type: "modal-off" }
|
|
51
|
+
| { type: "modal-preset"; preset: string }
|
|
52
|
+
| { type: "modal-usage"; message: string }
|
|
53
|
+
| { type: "modal-hint"; message: string }
|
|
54
|
+
| {
|
|
55
|
+
type: "modal-set";
|
|
56
|
+
/** `null` returns a parameter to the preset's value. */
|
|
57
|
+
values: Readonly<Record<string, number | string | null>>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** The voice `modal off` returns a track to: the legacy marimba tone. */
|
|
61
|
+
export const MODAL_OFF_INSTRUMENT = "marimba";
|
|
62
|
+
|
|
63
|
+
export const MODAL_USAGE =
|
|
64
|
+
"modal <preset> | modal <body> | modal <param> <value> | modal mallet <name> | modal pair <track> | modal gamelan | modal reset | modal off | modal presets";
|
|
65
|
+
|
|
66
|
+
function rangeOf(name: string): string {
|
|
67
|
+
const spec = MODAL_PARAMS[name]!;
|
|
68
|
+
if (spec.kind === "number") return `${name} is ${spec.min}..${spec.max}`;
|
|
69
|
+
if (spec.kind === "enum") return `${name} is one of ${spec.values.join(" ")}`;
|
|
70
|
+
return name;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function parseModalCommand(prompt: string): ModalCommand | undefined {
|
|
74
|
+
const words = prompt.trim().toLowerCase().split(/\s+/);
|
|
75
|
+
if (words[0] === "presets" && words[1] === "modal" && words.length === 2)
|
|
76
|
+
return { type: "modal-list" };
|
|
77
|
+
if (words[0] !== "modal") return undefined;
|
|
78
|
+
if (words.length === 1) return { type: "modal-show" };
|
|
79
|
+
if (prompt.length > 512) return undefined;
|
|
80
|
+
const rest = words.slice(1);
|
|
81
|
+
if (rest.length === 1 && rest[0] === "reset") return { type: "modal-reset" };
|
|
82
|
+
if (rest.length === 1 && rest[0] === "off") return { type: "modal-off" };
|
|
83
|
+
if (rest.length === 1 && (rest[0] === "list" || rest[0] === "presets"))
|
|
84
|
+
return { type: "modal-list" };
|
|
85
|
+
// `gamelan` is a browse group, not a preset: list the bronzes.
|
|
86
|
+
if (rest.length === 1 && rest[0] === "gamelan")
|
|
87
|
+
return {
|
|
88
|
+
type: "modal-hint",
|
|
89
|
+
message: `gamelan · ${GAMELAN_PRESETS.join(" ")} · modal gangsa to start · pair two tracks with modal pair <track> · tuning slendro or pelog · ctrl-k Sound › browse sounds › Mallets and bells › Gamelan`,
|
|
90
|
+
};
|
|
91
|
+
if (rest[0] === "preset") {
|
|
92
|
+
const preset = rest.length === 2 ? modalPresetFor(rest[1]!) : undefined;
|
|
93
|
+
return preset
|
|
94
|
+
? { type: "modal-preset", preset }
|
|
95
|
+
: {
|
|
96
|
+
type: "modal-usage",
|
|
97
|
+
message: `modal preset is one of ${MODAL_PRESET_NAMES.join(" ")}`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (rest.length === 1) {
|
|
101
|
+
const preset = modalPresetFor(rest[0]!);
|
|
102
|
+
if (preset) return { type: "modal-preset", preset };
|
|
103
|
+
// A body word with no preset of its own (frame, bell, crotale ...)
|
|
104
|
+
// sets the body.
|
|
105
|
+
const body = MODAL_PARAMS.body;
|
|
106
|
+
if (body?.kind === "enum" && body.values.includes(rest[0]!))
|
|
107
|
+
return { type: "modal-set", values: { body: rest[0]! } };
|
|
108
|
+
return { type: "modal-usage", message: MODAL_USAGE };
|
|
109
|
+
}
|
|
110
|
+
// f061-gamelan-winds: `modal pair <track>` makes this track the
|
|
111
|
+
// pengisep of an ombak pair (ids keep their case).
|
|
112
|
+
if (rest[0] === "pair" && rest.length === 2) {
|
|
113
|
+
const id = prompt.trim().split(/\s+/)[2]!;
|
|
114
|
+
if (id === "off" || id === "unset" || id === "none")
|
|
115
|
+
return { type: "modal-set", values: { pair: null } };
|
|
116
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(id))
|
|
117
|
+
return {
|
|
118
|
+
type: "modal-usage",
|
|
119
|
+
message: "modal pair <track id> | modal pair off",
|
|
120
|
+
};
|
|
121
|
+
return { type: "modal-set", values: { pair: id } };
|
|
122
|
+
}
|
|
123
|
+
if (rest.length % 2 !== 0)
|
|
124
|
+
return { type: "modal-usage", message: MODAL_USAGE };
|
|
125
|
+
const values: Record<string, number | string | null> = {};
|
|
126
|
+
for (let index = 0; index < rest.length; index += 2) {
|
|
127
|
+
const name = modalParamName(rest[index]!);
|
|
128
|
+
if (!name)
|
|
129
|
+
return {
|
|
130
|
+
type: "modal-usage",
|
|
131
|
+
message: `modal has no parameter ${rest[index]!.slice(0, 24)} · ${MODAL_SIMPLE.join(" ")} …`,
|
|
132
|
+
};
|
|
133
|
+
const word = rest[index + 1]!;
|
|
134
|
+
if (word === "off" || word === "unset") {
|
|
135
|
+
values[name] = null;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const value = parseParamValue(MODAL_PARAMS[name]!, word);
|
|
139
|
+
if (value === undefined || typeof value === "boolean")
|
|
140
|
+
return { type: "modal-usage", message: rangeOf(name) };
|
|
141
|
+
values[name] = value;
|
|
142
|
+
}
|
|
143
|
+
return { type: "modal-set", values };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `preset vibes · hardness 0.6 · ring 3`, overrides in canonical order. */
|
|
147
|
+
export function describeModal(modal: TrackModal | undefined): string {
|
|
148
|
+
if (!modal) return "not a modal track";
|
|
149
|
+
const preset = modal.preset ?? DEFAULT_MODAL_PRESET;
|
|
150
|
+
const overrides = Object.entries(modal)
|
|
151
|
+
.filter(([key]) => key !== "preset")
|
|
152
|
+
.map(([key, value]) => `${key} ${value}`);
|
|
153
|
+
return [`preset ${preset}`, ...overrides].join(" · ");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** One line per preset, for `modal list` and the agent brief. */
|
|
157
|
+
export function modalListLines(): string[] {
|
|
158
|
+
return MODAL_PRESET_NAMES.map(
|
|
159
|
+
(name) => `${name.padEnd(10)} ${MODAL_PRESETS[name].doc}`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export type ModalResult = Readonly<{
|
|
164
|
+
ok: boolean;
|
|
165
|
+
message: string;
|
|
166
|
+
next?: TrackScore;
|
|
167
|
+
kind?: string;
|
|
168
|
+
payload?: Record<string, unknown>;
|
|
169
|
+
}>;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The modal field a command leaves on a track (pure; the agent tool shares
|
|
173
|
+
* it). A preset switch keeps the overrides; `reset` keeps the preset.
|
|
174
|
+
* `mallet` and `hardness` are one choice, so the one written last wins.
|
|
175
|
+
*/
|
|
176
|
+
export function nextModal(
|
|
177
|
+
current: TrackModal | undefined,
|
|
178
|
+
command: Extract<
|
|
179
|
+
ModalCommand,
|
|
180
|
+
{ type: "modal-reset" | "modal-preset" | "modal-set" }
|
|
181
|
+
>,
|
|
182
|
+
): TrackModal {
|
|
183
|
+
const base: Record<string, unknown> = { ...current };
|
|
184
|
+
// `modal ombak 6` or `modal pair t2` on a track that is not modal yet
|
|
185
|
+
// means a gamelan pair: start from gangsa, not the default marimba.
|
|
186
|
+
if (
|
|
187
|
+
current === undefined &&
|
|
188
|
+
command.type === "modal-set" &&
|
|
189
|
+
command.values.preset === undefined &&
|
|
190
|
+
(typeof command.values.ombak === "number" ||
|
|
191
|
+
typeof command.values.pair === "string")
|
|
192
|
+
)
|
|
193
|
+
base.preset = "gangsa";
|
|
194
|
+
if (command.type === "modal-reset")
|
|
195
|
+
return normalizeModal(current?.preset ? { preset: current.preset } : {})!;
|
|
196
|
+
if (command.type === "modal-preset") base.preset = command.preset;
|
|
197
|
+
else
|
|
198
|
+
for (const [key, value] of Object.entries(command.values)) {
|
|
199
|
+
if (value === null) delete base[key];
|
|
200
|
+
else {
|
|
201
|
+
base[key] = value;
|
|
202
|
+
if (key === "mallet" && !("hardness" in command.values))
|
|
203
|
+
delete base.hardness;
|
|
204
|
+
if (key === "hardness" && !("mallet" in command.values))
|
|
205
|
+
delete base.mallet;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return normalizeModal(base)!;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function applyModalCommand(
|
|
212
|
+
score: TrackScore,
|
|
213
|
+
trackId: string,
|
|
214
|
+
command: ModalCommand,
|
|
215
|
+
): ModalResult {
|
|
216
|
+
if (command.type === "modal-usage")
|
|
217
|
+
return { ok: false, message: `modal · ${command.message}` };
|
|
218
|
+
if (command.type === "modal-hint")
|
|
219
|
+
return { ok: true, message: `modal · ${command.message}` };
|
|
220
|
+
if (command.type === "modal-list")
|
|
221
|
+
return {
|
|
222
|
+
ok: true,
|
|
223
|
+
message: `modal presets · ${MODAL_PRESET_NAMES.join(" ")}`,
|
|
224
|
+
};
|
|
225
|
+
const track = score.tracks.find((candidate) => candidate.id === trackId);
|
|
226
|
+
if (!track) return { ok: false, message: `no track · ${trackId}` };
|
|
227
|
+
if (command.type === "modal-show")
|
|
228
|
+
return {
|
|
229
|
+
ok: true,
|
|
230
|
+
message:
|
|
231
|
+
track.instrument === MODAL_INSTRUMENT && track.modal
|
|
232
|
+
? `modal · ${describeModal(track.modal)}`
|
|
233
|
+
: `modal · ${trackId} plays ${track.instrument} · modal <preset> to switch (${MODAL_PRESET_NAMES.join(" ")})`,
|
|
234
|
+
};
|
|
235
|
+
if (track.sampler || track.instrument === "kit")
|
|
236
|
+
return {
|
|
237
|
+
ok: false,
|
|
238
|
+
message: `modal · ${trackId} is a ${track.sampler ? "sampler" : "drum"} track`,
|
|
239
|
+
};
|
|
240
|
+
const current =
|
|
241
|
+
track.instrument === MODAL_INSTRUMENT ? track.modal : undefined;
|
|
242
|
+
if (command.type === "modal-off") {
|
|
243
|
+
if (track.instrument !== MODAL_INSTRUMENT)
|
|
244
|
+
return { ok: true, message: "modal · already off" };
|
|
245
|
+
// Like `string off`: leave the engine for the legacy voice.
|
|
246
|
+
const next = updateTrack(score, trackId, {
|
|
247
|
+
instrument: MODAL_OFF_INSTRUMENT,
|
|
248
|
+
modal: null,
|
|
249
|
+
});
|
|
250
|
+
return {
|
|
251
|
+
ok: true,
|
|
252
|
+
message: `modal · off (${MODAL_OFF_INSTRUMENT}) · modal ${current?.preset ?? DEFAULT_MODAL_PRESET} turns it back on`,
|
|
253
|
+
next,
|
|
254
|
+
kind: "score.modal",
|
|
255
|
+
payload: { trackId, instrument: MODAL_OFF_INSTRUMENT, modal: null },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
let modal: TrackModal;
|
|
259
|
+
let next: TrackScore;
|
|
260
|
+
let partnerNote = "";
|
|
261
|
+
try {
|
|
262
|
+
modal = nextModal(current, command);
|
|
263
|
+
if (
|
|
264
|
+
command.type === "modal-set" &&
|
|
265
|
+
typeof command.values.pair === "string"
|
|
266
|
+
) {
|
|
267
|
+
const partnerId = command.values.pair;
|
|
268
|
+
const partner = score.tracks.find((entry) => entry.id === partnerId);
|
|
269
|
+
if (!partner || partnerId === trackId)
|
|
270
|
+
return {
|
|
271
|
+
ok: false,
|
|
272
|
+
message: `modal · pair needs another track id (${score.tracks
|
|
273
|
+
.filter((entry) => entry.id !== trackId)
|
|
274
|
+
.map((entry) => entry.id)
|
|
275
|
+
.join(" ")})`,
|
|
276
|
+
};
|
|
277
|
+
if (partner.instrument !== MODAL_INSTRUMENT || !partner.modal)
|
|
278
|
+
return {
|
|
279
|
+
ok: false,
|
|
280
|
+
message: `modal · ${partnerId} is not a modal track · modal gangsa on it first`,
|
|
281
|
+
};
|
|
282
|
+
// The partner (pengumbang) plays straight (ombak 0) and this track
|
|
283
|
+
// (pengisep) sounds ombak Hz above it, so the pair beats at ombak Hz.
|
|
284
|
+
if (modalSettings(partner.modal).ombak !== 0) {
|
|
285
|
+
score = updateTrack(score, partnerId, {
|
|
286
|
+
modal: normalizeModal({ ...partner.modal, ombak: 0 })!,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
partnerNote = ` · ${partnerId} is the pengumbang (ombak 0)`;
|
|
290
|
+
}
|
|
291
|
+
next = updateTrack(score, trackId, {
|
|
292
|
+
instrument: MODAL_INSTRUMENT,
|
|
293
|
+
modal,
|
|
294
|
+
});
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (
|
|
297
|
+
error instanceof ScoreValidationError ||
|
|
298
|
+
error instanceof FxValidationError
|
|
299
|
+
)
|
|
300
|
+
return { ok: false, message: `modal · ${error.message}` };
|
|
301
|
+
throw error;
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
ok: true,
|
|
305
|
+
message: `modal · ${describeModal(modal)}${partnerNote}`,
|
|
306
|
+
next,
|
|
307
|
+
kind: "score.modal",
|
|
308
|
+
payload: { trackId, instrument: MODAL_INSTRUMENT, modal },
|
|
309
|
+
};
|
|
310
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `resample <track> | orbit <n> | master [section <name> | bars a-b] [post]
|
|
3
|
+
* [grain] [as <id>]` (0.6.1): render a track, an orbit or the mix to
|
|
4
|
+
* `tracks/<slug>/samples/<name>.wav`, pin its sha256, record where it came
|
|
5
|
+
* from (`from`: source, section or bars, the source score's sha256) and add
|
|
6
|
+
* a track that plays it: a one-shot sampler track whose single note starts
|
|
7
|
+
* where the range starts and lasts the range, or with `grain` a granular
|
|
8
|
+
* track (the `cloud` preset) holding one note across the range. The source
|
|
9
|
+
* stays as it is; mute it to hear only the resample.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdir, realpath, rename, writeFile } from "node:fs/promises";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { MAX_ORBIT } from "../../core/fx.ts";
|
|
14
|
+
import { GRANULAR_INSTRUMENT } from "../../core/granular.ts";
|
|
15
|
+
import {
|
|
16
|
+
ScoreValidationError,
|
|
17
|
+
SCORE_LIMITS,
|
|
18
|
+
TrackScore,
|
|
19
|
+
addTrack,
|
|
20
|
+
type SampleRef,
|
|
21
|
+
type TrackInput,
|
|
22
|
+
} from "../../core/score.ts";
|
|
23
|
+
import { trackDirectories } from "../../core/sdk/print.ts";
|
|
24
|
+
import { barTicks } from "../../core/sections.ts";
|
|
25
|
+
import {
|
|
26
|
+
RESAMPLE_GAIN,
|
|
27
|
+
renderResample,
|
|
28
|
+
type ResampleRender,
|
|
29
|
+
type ResampleRequest,
|
|
30
|
+
} from "../audio/resample.ts";
|
|
31
|
+
import type { SampleBank } from "../audio/samples.ts";
|
|
32
|
+
|
|
33
|
+
export type ResampleCommand = ResampleRequest &
|
|
34
|
+
Readonly<{
|
|
35
|
+
/** Make a granular track instead of a sampler track. */
|
|
36
|
+
grain: boolean;
|
|
37
|
+
/** The new track's id (default `<source>-rs` or `<source>-grain`). */
|
|
38
|
+
as?: string;
|
|
39
|
+
}>;
|
|
40
|
+
|
|
41
|
+
export const RESAMPLE_USAGE =
|
|
42
|
+
"resample <track>|orbit <n>|master [section <name>|bars a-b] [post] [grain] [as <id>]";
|
|
43
|
+
|
|
44
|
+
const ID = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
45
|
+
|
|
46
|
+
/** Parse `resample ...` (with or without the slash). */
|
|
47
|
+
export function parseResampleCommand(
|
|
48
|
+
command: string,
|
|
49
|
+
): ResampleCommand | undefined {
|
|
50
|
+
const words = command.trim().split(/\s+/);
|
|
51
|
+
const head = words[0]?.toLowerCase().replace(/^\//, "");
|
|
52
|
+
if (head !== "resample" && head !== "bounce") return undefined;
|
|
53
|
+
let index = 1;
|
|
54
|
+
const next = () => words[index++];
|
|
55
|
+
const peek = () => words[index]?.toLowerCase();
|
|
56
|
+
let source: ResampleRequest["source"] | undefined;
|
|
57
|
+
const first = next();
|
|
58
|
+
if (first === undefined) return undefined;
|
|
59
|
+
if (first.toLowerCase() === "orbit") {
|
|
60
|
+
const n = Number(next());
|
|
61
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_ORBIT) return undefined;
|
|
62
|
+
source = { kind: "orbit", orbit: n };
|
|
63
|
+
} else if (first.toLowerCase() === "master" || first.toLowerCase() === "mix")
|
|
64
|
+
source = { kind: "master" };
|
|
65
|
+
else if (first.toLowerCase() === "track") {
|
|
66
|
+
const id = next();
|
|
67
|
+
if (!id) return undefined;
|
|
68
|
+
source = { kind: "track", trackId: id };
|
|
69
|
+
} else source = { kind: "track", trackId: first };
|
|
70
|
+
let range: ResampleRequest["range"] = { kind: "song" };
|
|
71
|
+
let post = false;
|
|
72
|
+
let grain = false;
|
|
73
|
+
let as: string | undefined;
|
|
74
|
+
while (index < words.length) {
|
|
75
|
+
const word = peek();
|
|
76
|
+
index += 1;
|
|
77
|
+
if (word === "section") {
|
|
78
|
+
// A section name runs to the next keyword.
|
|
79
|
+
const parts: string[] = [];
|
|
80
|
+
while (
|
|
81
|
+
index < words.length &&
|
|
82
|
+
!["post", "grain", "granular", "as", "bars"].includes(peek()!)
|
|
83
|
+
)
|
|
84
|
+
parts.push(next()!);
|
|
85
|
+
if (parts.length === 0) return undefined;
|
|
86
|
+
range = { kind: "section", name: parts.join(" ") };
|
|
87
|
+
} else if (word === "bars" || word === "bar") {
|
|
88
|
+
const match = /^(\d{1,4})(?:-(\d{1,4}))?$/.exec(next() ?? "");
|
|
89
|
+
if (!match) return undefined;
|
|
90
|
+
const from = Number(match[1]);
|
|
91
|
+
range = { kind: "bars", from, to: Number(match[2] ?? from) };
|
|
92
|
+
} else if (word === "post" || word === "master") post = true;
|
|
93
|
+
else if (word === "pre") post = false;
|
|
94
|
+
else if (word === "grain" || word === "granular") grain = true;
|
|
95
|
+
else if (word === "as") {
|
|
96
|
+
as = next()?.toLowerCase();
|
|
97
|
+
if (!as || !ID.test(as)) return undefined;
|
|
98
|
+
} else return undefined;
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
source,
|
|
102
|
+
range,
|
|
103
|
+
...(post ? { post } : {}),
|
|
104
|
+
grain,
|
|
105
|
+
...(as ? { as } : {}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A free track id near `base`: `base`, `base-2`, … */
|
|
110
|
+
export function freeTrackId(score: TrackScore, base: string): string {
|
|
111
|
+
const stem = base.slice(0, 56);
|
|
112
|
+
const used = new Set(score.tracks.map((track) => track.id));
|
|
113
|
+
if (!used.has(stem)) return stem;
|
|
114
|
+
for (let n = 2; ; n += 1)
|
|
115
|
+
if (!used.has(`${stem}-${n}`)) return `${stem}-${n}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function sourceStem(command: ResampleCommand): string {
|
|
119
|
+
const source = command.source;
|
|
120
|
+
const base =
|
|
121
|
+
source.kind === "track"
|
|
122
|
+
? source.trackId.toLowerCase().replace(/[^a-z0-9_-]/g, "")
|
|
123
|
+
: source.kind === "orbit"
|
|
124
|
+
? `orbit${source.orbit}`
|
|
125
|
+
: "mix";
|
|
126
|
+
return /^[a-z]/.test(base) ? base : `t${base}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The sample's file name (without extension) from source and range. */
|
|
130
|
+
export function resampleFileName(command: ResampleCommand): string {
|
|
131
|
+
const range = command.range;
|
|
132
|
+
const suffix =
|
|
133
|
+
range.kind === "section"
|
|
134
|
+
? `-${range.name
|
|
135
|
+
.toLowerCase()
|
|
136
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
137
|
+
.replace(/^-|-$/g, "")}`
|
|
138
|
+
: range.kind === "bars"
|
|
139
|
+
? `-b${range.from}-${range.to}`
|
|
140
|
+
: "";
|
|
141
|
+
return `${sourceStem(command)}${suffix}`.slice(0, 48) || "resample";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The score with the new track: a one-shot sampler voice (or a granular
|
|
146
|
+
* source) at `src`, and its one note across the range.
|
|
147
|
+
*/
|
|
148
|
+
export function addResampleTrack(
|
|
149
|
+
score: TrackScore,
|
|
150
|
+
command: ResampleCommand,
|
|
151
|
+
render: Pick<
|
|
152
|
+
ResampleRender,
|
|
153
|
+
"sha256" | "startTick" | "durationTicks" | "from"
|
|
154
|
+
>,
|
|
155
|
+
trackId: string,
|
|
156
|
+
src: string,
|
|
157
|
+
): TrackScore {
|
|
158
|
+
const ref: SampleRef = {
|
|
159
|
+
src,
|
|
160
|
+
sha256: render.sha256,
|
|
161
|
+
from: render.from,
|
|
162
|
+
// A bounce starts at full level: no anti-click fade-in.
|
|
163
|
+
...(command.grain ? {} : { gain: RESAMPLE_GAIN, fadeInTime: 0 }),
|
|
164
|
+
};
|
|
165
|
+
const track: TrackInput = command.grain
|
|
166
|
+
? {
|
|
167
|
+
id: trackId,
|
|
168
|
+
name: trackId,
|
|
169
|
+
instrument: GRANULAR_INSTRUMENT,
|
|
170
|
+
granular: { src: ref, preset: "cloud" },
|
|
171
|
+
}
|
|
172
|
+
: {
|
|
173
|
+
id: trackId,
|
|
174
|
+
name: trackId,
|
|
175
|
+
instrument: "sampler",
|
|
176
|
+
sampler: {
|
|
177
|
+
mode: "oneshot",
|
|
178
|
+
voices: { [trackId.replace(/-/g, "_").slice(0, 32)]: ref },
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
let next = addTrack(score, track as TrackInput);
|
|
182
|
+
// One-shot slot 36 plays the only voice; a granular note at its root (60)
|
|
183
|
+
// plays the source at its own pitch.
|
|
184
|
+
const pitch = command.grain ? 60 : 36;
|
|
185
|
+
const notes = next.notes;
|
|
186
|
+
let n = 1;
|
|
187
|
+
while (notes.some((note) => note.id === `${trackId}-${n}`)) n += 1;
|
|
188
|
+
const ticks = Math.min(render.durationTicks, next.bars * barTicks(next));
|
|
189
|
+
next = new TrackScore({
|
|
190
|
+
...next.toJSON(),
|
|
191
|
+
notes: [
|
|
192
|
+
...notes,
|
|
193
|
+
{
|
|
194
|
+
id: `${trackId}-${n}`,
|
|
195
|
+
trackId,
|
|
196
|
+
startTick: render.startTick,
|
|
197
|
+
durationTicks: Math.max(1, ticks),
|
|
198
|
+
pitch,
|
|
199
|
+
velocity: 1,
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
} as never);
|
|
203
|
+
return next;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export type ResampleResult =
|
|
207
|
+
| Readonly<{
|
|
208
|
+
ok: true;
|
|
209
|
+
next: TrackScore;
|
|
210
|
+
trackId: string;
|
|
211
|
+
src: string;
|
|
212
|
+
sha256: string;
|
|
213
|
+
message: string;
|
|
214
|
+
}>
|
|
215
|
+
| Readonly<{ ok: false; message: string }>;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Render, write the file under the new track's directory and return the
|
|
219
|
+
* score with the new track. `samples` is the loaded bank of `score`.
|
|
220
|
+
*/
|
|
221
|
+
export async function runResample(
|
|
222
|
+
options: Readonly<{
|
|
223
|
+
projectRoot: string;
|
|
224
|
+
score: TrackScore;
|
|
225
|
+
command: ResampleCommand;
|
|
226
|
+
samples?: SampleBank;
|
|
227
|
+
}>,
|
|
228
|
+
): Promise<ResampleResult> {
|
|
229
|
+
const { score, command } = options;
|
|
230
|
+
if (score.tracks.length >= SCORE_LIMITS.maxTracks)
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
message: `resample · the song already has ${SCORE_LIMITS.maxTracks} tracks`,
|
|
234
|
+
};
|
|
235
|
+
let render: ResampleRender;
|
|
236
|
+
try {
|
|
237
|
+
render = renderResample(score, command, options.samples);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (error instanceof ScoreValidationError)
|
|
240
|
+
return {
|
|
241
|
+
ok: false,
|
|
242
|
+
message: `resample · ${error.message} · ${RESAMPLE_USAGE}`,
|
|
243
|
+
};
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
const trackId =
|
|
247
|
+
command.as ??
|
|
248
|
+
freeTrackId(
|
|
249
|
+
score,
|
|
250
|
+
`${sourceStem(command)}-${command.grain ? "grain" : "rs"}`,
|
|
251
|
+
);
|
|
252
|
+
if (score.tracks.some((track) => track.id === trackId))
|
|
253
|
+
return { ok: false, message: `resample · track ${trackId} already exists` };
|
|
254
|
+
// The directory follows the score that will hold the track.
|
|
255
|
+
const probe = addTrack(score, { id: trackId, name: trackId });
|
|
256
|
+
const slug = trackDirectories(probe).get(trackId) ?? trackId;
|
|
257
|
+
const root = await realpath(options.projectRoot);
|
|
258
|
+
const dir = join(root, "tracks", slug, "samples");
|
|
259
|
+
await mkdir(dir, { recursive: true });
|
|
260
|
+
const name = `${resampleFileName(command)}.wav`;
|
|
261
|
+
const target = join(dir, name);
|
|
262
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
263
|
+
await writeFile(temporary, render.wav);
|
|
264
|
+
await rename(temporary, target);
|
|
265
|
+
const src = `samples/${name}`;
|
|
266
|
+
const next = addResampleTrack(score, command, render, trackId, src);
|
|
267
|
+
const what =
|
|
268
|
+
command.source.kind === "track"
|
|
269
|
+
? command.source.trackId
|
|
270
|
+
: command.source.kind === "orbit"
|
|
271
|
+
? `orbit ${command.source.orbit}`
|
|
272
|
+
: "the mix";
|
|
273
|
+
return {
|
|
274
|
+
ok: true,
|
|
275
|
+
next,
|
|
276
|
+
trackId,
|
|
277
|
+
src: `tracks/${slug}/${src}`,
|
|
278
|
+
sha256: render.sha256,
|
|
279
|
+
message: `resampled ${what} · ${render.seconds.toFixed(2)} s → tracks/${slug}/${src} · ${render.sha256.slice(0, 12)} · new ${command.grain ? "granular" : "sampler"} track ${trackId}${command.source.kind === "track" ? ` · mute ${command.source.trackId} to hear only the resample` : ""}`,
|
|
280
|
+
};
|
|
281
|
+
}
|