@hraness/dawg 0.6.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 +34 -0
- package/DAWG.md +338 -51
- package/README.md +4 -4
- package/core/chords.ts +492 -4
- package/core/diff.ts +5 -0
- package/core/expression.ts +143 -6
- package/core/fx.ts +258 -6
- package/core/granular.ts +91 -0
- package/core/instruments.ts +161 -0
- package/core/keys.ts +573 -4
- package/core/resonators.ts +291 -3
- package/core/score.ts +349 -3
- package/core/sdk/print.ts +78 -3
- package/core/sdk/v1.ts +1320 -36
- package/core/sections.ts +12 -4
- package/core/strings.ts +237 -2
- package/core/winds.ts +652 -0
- package/guides/automation.md +1 -0
- package/guides/chords.md +3 -1
- package/guides/effects.md +3 -2
- package/guides/media.md +1 -1
- package/guides/performance.md +3 -0
- package/guides/resample.md +26 -0
- package/guides/sounds.md +7 -6
- package/package.json +1 -1
- package/src/agent/agent.ts +8 -2
- package/src/agent/chord-tools.ts +153 -1
- package/src/agent/expression-tools.ts +91 -0
- package/src/agent/ops.ts +4 -1
- package/src/agent/preview-tool.ts +2 -1
- package/src/agent/resample-tool.ts +131 -0
- package/src/agent/tools.ts +200 -15
- package/src/agent/xcb-agent.ts +8 -2
- package/src/audio/arrange.ts +21 -0
- package/src/audio/dsp/envelope.ts +161 -0
- package/src/audio/dsp/shift.ts +256 -0
- package/src/audio/effects/chain.ts +18 -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/engine.ts +46 -5
- package/src/audio/fit.ts +45 -0
- package/src/audio/granular.ts +352 -21
- package/src/audio/instrument-check.ts +9 -5
- package/src/audio/instruments.ts +32 -3
- package/src/audio/keys/electric.ts +420 -0
- package/src/audio/keys/engine.ts +143 -19
- package/src/audio/keys/organ.ts +1335 -0
- package/src/audio/keys/piano.ts +50 -6
- package/src/audio/keys/sympathetic.ts +127 -0
- package/src/audio/live-worker.ts +3 -1
- package/src/audio/live.ts +136 -9
- package/src/audio/preview.ts +15 -1
- package/src/audio/resample.ts +285 -0
- package/src/audio/resonators.ts +10 -2
- package/src/audio/sampler.ts +168 -21
- package/src/audio/samples.ts +26 -1
- package/src/audio/strings/body.ts +29 -16
- package/src/audio/strings/bow.ts +656 -0
- package/src/audio/strings/engine.ts +266 -20
- package/src/audio/strings/measure.test-helpers.ts +2 -0
- package/src/audio/wav.ts +119 -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 +103 -22
- package/src/commands/fx.ts +4 -1
- package/src/commands/granular.ts +7 -2
- package/src/commands/help.ts +91 -8
- package/src/commands/keys.ts +287 -11
- package/src/commands/modal.ts +67 -4
- package/src/commands/resample.ts +281 -0
- package/src/commands/rig.ts +5 -1
- package/src/commands/sample.ts +49 -2
- package/src/commands/shift.ts +119 -0
- package/src/commands/string.ts +28 -2
- package/src/commands/strum.ts +473 -0
- package/src/commands/wind.ts +244 -0
- package/src/main.ts +129 -7
- package/src/project/check.ts +5 -1
- package/src/render.ts +16 -0
- package/src/session/presence.ts +16 -3
- package/src/tui/audition.ts +1 -1
- package/src/tui/menu.ts +637 -35
- package/src/tui/modal-menu.ts +34 -7
- 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 +34 -9
- package/src/tui/wind-menu.ts +144 -0
|
@@ -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
|
+
}
|
package/src/commands/rig.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
type FxValues,
|
|
28
28
|
type RigStage,
|
|
29
29
|
type TrackFx,
|
|
30
|
+
SHOEGAZE_EFFECTS,
|
|
30
31
|
} from "../../core/fx.ts";
|
|
31
32
|
import {
|
|
32
33
|
instrumentForWord,
|
|
@@ -140,7 +141,7 @@ export function parseRigCommand(prompt: string): RigCommand | undefined {
|
|
|
140
141
|
}
|
|
141
142
|
|
|
142
143
|
function describeRig(values: Readonly<Record<string, FxValues | undefined>>) {
|
|
143
|
-
|
|
144
|
+
const stages = RIG_STAGES.filter((stage) => values[stage])
|
|
144
145
|
.map((stage) => {
|
|
145
146
|
const v = values[stage]!;
|
|
146
147
|
const type = String(v.type);
|
|
@@ -148,6 +149,9 @@ function describeRig(values: Readonly<Record<string, FxValues | undefined>>) {
|
|
|
148
149
|
return `${stage} ${type}${gain}`;
|
|
149
150
|
})
|
|
150
151
|
.join(" → ");
|
|
152
|
+
// 0.6.1 shoegaze stages follow the cab in the chain.
|
|
153
|
+
const gaze = SHOEGAZE_EFFECTS.filter((effect) => values[effect]);
|
|
154
|
+
return gaze.length ? `${stages} + ${gaze.join(" ")}` : stages;
|
|
151
155
|
}
|
|
152
156
|
|
|
153
157
|
export function applyRigCommand(
|
package/src/commands/sample.ts
CHANGED
|
@@ -38,7 +38,8 @@ export type SampleCommand =
|
|
|
38
38
|
}>;
|
|
39
39
|
|
|
40
40
|
/** A sample control value: a number, a switch, a unit, or null to unset. */
|
|
41
|
-
export type SampleControlValue =
|
|
41
|
+
export type SampleControlValue =
|
|
42
|
+
number | string | boolean | null | readonly number[];
|
|
42
43
|
|
|
43
44
|
/**
|
|
44
45
|
* Per-voice sample controls `/sample set` and `set_sample` change, by
|
|
@@ -62,6 +63,12 @@ export const SAMPLE_CONTROLS = Object.freeze({
|
|
|
62
63
|
bpm: "the sample's own tempo: it follows the song's tempo map",
|
|
63
64
|
fitmode: "how a fitted sample changes time: repitch, beats or tones",
|
|
64
65
|
len: "the window lasts n beats of the song's tempo map",
|
|
66
|
+
shift: "pitch in semitones (−24..24), length unchanged",
|
|
67
|
+
formant: "formants in semitones with shift: 0 keeps the voice, off follows",
|
|
68
|
+
fadeTime: "release fade in seconds (0..2); alias fadeout",
|
|
69
|
+
fadeInTime: "attack fade in seconds (0..2); alias fadein",
|
|
70
|
+
vel: "velocity layer lo-hi (MIDI 0-127): plays only for those velocities",
|
|
71
|
+
rr: "round-robin group name: voices in a group take turns",
|
|
65
72
|
});
|
|
66
73
|
export type SampleControl = keyof typeof SAMPLE_CONTROLS;
|
|
67
74
|
|
|
@@ -70,6 +77,8 @@ const SAMPLE_ALIASES: Readonly<Record<string, string>> = {
|
|
|
70
77
|
loope: "loopEnd",
|
|
71
78
|
legato: "clip",
|
|
72
79
|
choke: "cut",
|
|
80
|
+
fadeout: "fadeTime",
|
|
81
|
+
fadein: "fadeInTime",
|
|
73
82
|
};
|
|
74
83
|
|
|
75
84
|
function controlName(raw: string): SampleControl | undefined {
|
|
@@ -176,6 +185,18 @@ export function setSampleControls(
|
|
|
176
185
|
continue;
|
|
177
186
|
}
|
|
178
187
|
if (name === "cut" && typeof value === "number") value = `cut${value}`;
|
|
188
|
+
if (name === "rr" && typeof value === "number") value = `rr${value}`;
|
|
189
|
+
if (name === "vel" && value !== null) {
|
|
190
|
+
const range = parseVelocityRange(value);
|
|
191
|
+
if (!range)
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
message: "sample · vel needs lo-hi MIDI velocities, e.g. vel 0-63",
|
|
195
|
+
};
|
|
196
|
+
next.vel = range;
|
|
197
|
+
changed.push(`vel ${range[0]}-${range[1]}`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
179
200
|
if (value === null) delete next[field];
|
|
180
201
|
else next[field] = value;
|
|
181
202
|
changed.push(`${raw} ${value === null ? "off" : String(value)}`);
|
|
@@ -203,10 +224,24 @@ export function setSampleControls(
|
|
|
203
224
|
return {
|
|
204
225
|
ok: true,
|
|
205
226
|
next: updated,
|
|
206
|
-
message: `sample · ${trackId}/${voice} · ${changed.join(" · ")}`,
|
|
227
|
+
message: `sample · ${trackId}/${voice} · ${changed.join(" · ")}${layerHint(updated, trackId, voice)}`,
|
|
207
228
|
};
|
|
208
229
|
}
|
|
209
230
|
|
|
231
|
+
/** `0-63`, `0..63`, `64` (one velocity) → [lo, hi]; else undefined. */
|
|
232
|
+
export function parseVelocityRange(
|
|
233
|
+
value: SampleControlValue | readonly number[],
|
|
234
|
+
): [number, number] | undefined {
|
|
235
|
+
if (Array.isArray(value))
|
|
236
|
+
return value.length === 2 && value.every((v) => typeof v === "number")
|
|
237
|
+
? [value[0] as number, value[1] as number]
|
|
238
|
+
: undefined;
|
|
239
|
+
if (typeof value === "number") return [value, value];
|
|
240
|
+
if (typeof value !== "string") return undefined;
|
|
241
|
+
const match = value.trim().match(/^(\d{1,3})\s*(?:-|\.\.|,)\s*(\d{1,3})$/);
|
|
242
|
+
return match ? [Number(match[1]), Number(match[2])] : undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
210
245
|
function unquote(value: string): string {
|
|
211
246
|
const quoted = value.match(/^(["'])(.*)\1$/);
|
|
212
247
|
return quoted ? quoted[2]! : value;
|
|
@@ -372,6 +407,18 @@ export function freeVoiceName(
|
|
|
372
407
|
}
|
|
373
408
|
}
|
|
374
409
|
|
|
410
|
+
/**
|
|
411
|
+
* One-shot velocity layers swap only within a round-robin group: a kit
|
|
412
|
+
* voice with `vel` and no `rr` keeps its own pad, so say how to join them.
|
|
413
|
+
*/
|
|
414
|
+
function layerHint(score: TrackScore, trackId: string, voice: string): string {
|
|
415
|
+
const sampler = score.tracks.find((item) => item.id === trackId)?.sampler;
|
|
416
|
+
const ref = sampler?.voices[voice];
|
|
417
|
+
if (sampler?.mode !== "oneshot" || !ref?.vel || ref.rr !== undefined)
|
|
418
|
+
return "";
|
|
419
|
+
return ` · one-shot layers share a pad only within an rr group: sample ${voice} rr <group> on each layer`;
|
|
420
|
+
}
|
|
421
|
+
|
|
375
422
|
export class SamplePlacementError extends Error {}
|
|
376
423
|
|
|
377
424
|
/**
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shift <semitones> [formant keep|follow|<n>] [<voice>]` and
|
|
3
|
+
* `fade [in|out] <seconds> [<voice>]` (0.6.1): pitch a sampler voice
|
|
4
|
+
* without changing its length, and set its fade in and out. They act on
|
|
5
|
+
* the focused sampler track's voice (named, or its only one) through
|
|
6
|
+
* `setSampleControls`, so validation and printing stay the sampler's.
|
|
7
|
+
*/
|
|
8
|
+
import type { TrackScore } from "../../core/score.ts";
|
|
9
|
+
import { fitVoice } from "./fit.ts";
|
|
10
|
+
import {
|
|
11
|
+
setSampleControls,
|
|
12
|
+
type SampleControlValue,
|
|
13
|
+
type SetSampleResult,
|
|
14
|
+
} from "./sample.ts";
|
|
15
|
+
|
|
16
|
+
export type ShiftCommand =
|
|
17
|
+
| Readonly<{
|
|
18
|
+
kind: "shift";
|
|
19
|
+
/** Semitones; null clears shift and formant. */
|
|
20
|
+
semitones: number | null;
|
|
21
|
+
/** 0 keeps the formants, null follows the pitch, undefined leaves it. */
|
|
22
|
+
formant?: number | null;
|
|
23
|
+
voice?: string;
|
|
24
|
+
}>
|
|
25
|
+
| Readonly<{
|
|
26
|
+
kind: "fade";
|
|
27
|
+
/** `in` is fadeInTime, `out` fadeTime, `both` sets each. */
|
|
28
|
+
edge: "in" | "out" | "both";
|
|
29
|
+
seconds: number | null;
|
|
30
|
+
voice?: string;
|
|
31
|
+
}>;
|
|
32
|
+
|
|
33
|
+
const VOICE = "([a-z][a-z0-9_]{0,31})";
|
|
34
|
+
const NUMBER = "([+-]?\\d+(?:\\.\\d+)?|off|none)";
|
|
35
|
+
|
|
36
|
+
/** Parse `shift ...` and `fade ...` (with or without the slash). */
|
|
37
|
+
export function parseShiftCommand(command: string): ShiftCommand | undefined {
|
|
38
|
+
const text = command.trim();
|
|
39
|
+
const shift = text.match(
|
|
40
|
+
new RegExp(
|
|
41
|
+
`^/?shift\\s+${NUMBER}(?:\\s+formant\\s+(keep|follow|[+-]?\\d+(?:\\.\\d+)?))?(?:\\s+(?:voice\\s+)?${VOICE})?$`,
|
|
42
|
+
"i",
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
if (shift) {
|
|
46
|
+
const raw = shift[1]!.toLowerCase();
|
|
47
|
+
const formant = shift[2]?.toLowerCase();
|
|
48
|
+
return {
|
|
49
|
+
kind: "shift",
|
|
50
|
+
semitones: raw === "off" || raw === "none" ? null : Number(raw),
|
|
51
|
+
...(formant === undefined
|
|
52
|
+
? {}
|
|
53
|
+
: {
|
|
54
|
+
formant:
|
|
55
|
+
formant === "keep"
|
|
56
|
+
? 0
|
|
57
|
+
: formant === "follow"
|
|
58
|
+
? null
|
|
59
|
+
: Number(formant),
|
|
60
|
+
}),
|
|
61
|
+
...(shift[3] ? { voice: shift[3].toLowerCase() } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const fade = text.match(
|
|
65
|
+
new RegExp(
|
|
66
|
+
`^/?fade(?:\\s+(in|out))?\\s+${NUMBER}(?:\\s+(?:voice\\s+)?${VOICE})?$`,
|
|
67
|
+
"i",
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
if (!fade) return undefined;
|
|
71
|
+
const raw = fade[2]!.toLowerCase();
|
|
72
|
+
return {
|
|
73
|
+
kind: "fade",
|
|
74
|
+
edge: (fade[1]?.toLowerCase() as "in" | "out" | undefined) ?? "both",
|
|
75
|
+
seconds: raw === "off" || raw === "none" ? null : Number(raw),
|
|
76
|
+
...(fade[3] ? { voice: fade[3].toLowerCase() } : {}),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Apply a parsed `shift` or `fade` to the focused track's voice. */
|
|
81
|
+
export function applyShiftCommand(
|
|
82
|
+
score: TrackScore,
|
|
83
|
+
trackId: string,
|
|
84
|
+
command: ShiftCommand,
|
|
85
|
+
): SetSampleResult {
|
|
86
|
+
const target = fitVoice(score, trackId, command.voice);
|
|
87
|
+
if ("error" in target)
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
message: target.error.replace(/^fit · /, `${command.kind} · `),
|
|
91
|
+
};
|
|
92
|
+
const values: Record<string, SampleControlValue> = {};
|
|
93
|
+
if (command.kind === "shift") {
|
|
94
|
+
// `shift off` clears the shift and its formant setting. `shift 0`
|
|
95
|
+
// clears the shift and a kept formant (0, a no-op without a shift) but
|
|
96
|
+
// keeps a formant move, so `shift 0 formant 3` is a formant-only move
|
|
97
|
+
// and stepping the shift through 0 keeps a stored formant.
|
|
98
|
+
if (command.semitones === null) {
|
|
99
|
+
values.shift = null;
|
|
100
|
+
values.formant = null;
|
|
101
|
+
} else {
|
|
102
|
+
values.shift = command.semitones === 0 ? null : command.semitones;
|
|
103
|
+
const formant =
|
|
104
|
+
command.formant !== undefined
|
|
105
|
+
? command.formant
|
|
106
|
+
: score.tracks.find((track) => track.id === trackId)?.sampler?.voices[
|
|
107
|
+
target.voice
|
|
108
|
+
]?.formant;
|
|
109
|
+
if (command.semitones === 0 && (formant === 0 || formant === null))
|
|
110
|
+
values.formant = null;
|
|
111
|
+
else if (command.formant !== undefined) values.formant = command.formant;
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
const seconds = command.seconds === 0 ? null : command.seconds;
|
|
115
|
+
if (command.edge !== "out") values.fadeInTime = seconds;
|
|
116
|
+
if (command.edge !== "in") values.fadeTime = seconds;
|
|
117
|
+
}
|
|
118
|
+
return setSampleControls(score, trackId, target.voice, values);
|
|
119
|
+
}
|
package/src/commands/string.ts
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
* string <param> off back to the preset's value
|
|
10
10
|
* string reset drop the overrides, keep the preset
|
|
11
11
|
* string off back to the track's synth voice
|
|
12
|
+
* bowed play the cello preset (bowed strings)
|
|
13
|
+
* bowed <preset> a bowed preset (violin viola cello …)
|
|
14
|
+
* bowed presets the bowed presets
|
|
12
15
|
*
|
|
13
16
|
* `string sitar`, `string buzz 0.8 sym 0.5`, `string ring 6`. Each command
|
|
14
17
|
* is one `updateTrack` revision and one undo step.
|
|
@@ -20,6 +23,8 @@ import {
|
|
|
20
23
|
type TrackScore,
|
|
21
24
|
} from "../../core/score.ts";
|
|
22
25
|
import {
|
|
26
|
+
BOWED_PRESET_NAMES,
|
|
27
|
+
DEFAULT_BOWED_PRESET,
|
|
23
28
|
DEFAULT_STRING_PRESET,
|
|
24
29
|
isStringTrack,
|
|
25
30
|
normalizeString,
|
|
@@ -37,7 +42,7 @@ import { parseParamValue } from "./fx.ts";
|
|
|
37
42
|
|
|
38
43
|
export type StringCommand =
|
|
39
44
|
| { type: "string-list" }
|
|
40
|
-
| { type: "string-presets" }
|
|
45
|
+
| { type: "string-presets"; bowed?: boolean }
|
|
41
46
|
| { type: "string-reset" }
|
|
42
47
|
| { type: "string-off" }
|
|
43
48
|
| { type: "string-preset"; preset: string }
|
|
@@ -49,6 +54,7 @@ export type StringCommand =
|
|
|
49
54
|
|
|
50
55
|
export function parseStringCommand(prompt: string): StringCommand | undefined {
|
|
51
56
|
const words = prompt.trim().split(/\s+/);
|
|
57
|
+
if (words[0]?.toLowerCase() === "bowed") return parseBowed(words.slice(1));
|
|
52
58
|
if (words[0]?.toLowerCase() !== "string") return undefined;
|
|
53
59
|
if (words.length === 1) return { type: "string-list" };
|
|
54
60
|
if (prompt.length > 1_024) return undefined;
|
|
@@ -82,6 +88,26 @@ export function parseStringCommand(prompt: string): StringCommand | undefined {
|
|
|
82
88
|
return { type: "string-set", values };
|
|
83
89
|
}
|
|
84
90
|
|
|
91
|
+
/**
|
|
92
|
+
* `bowed` (0.6.1): the string engine's bowed family. `bowed` plays cello,
|
|
93
|
+
* `bowed <preset>` a bowed preset, `bowed presets` lists them, and
|
|
94
|
+
* `bowed <param> <value>…` is `string <param> <value>…`.
|
|
95
|
+
*/
|
|
96
|
+
function parseBowed(words: string[]): StringCommand | undefined {
|
|
97
|
+
if (words.length === 0)
|
|
98
|
+
return { type: "string-preset", preset: DEFAULT_BOWED_PRESET };
|
|
99
|
+
const rest = words.map((word) => word.toLowerCase());
|
|
100
|
+
if (rest.length === 1 && (rest[0] === "presets" || rest[0] === "list"))
|
|
101
|
+
return { type: "string-presets", bowed: true };
|
|
102
|
+
if (rest.length === 1) {
|
|
103
|
+
const preset = stringPresetName(rest[0]!);
|
|
104
|
+
return preset && BOWED_PRESET_NAMES.includes(preset)
|
|
105
|
+
? { type: "string-preset", preset }
|
|
106
|
+
: undefined;
|
|
107
|
+
}
|
|
108
|
+
return parseStringCommand(`string ${rest.join(" ")}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
85
111
|
/** `sitar · buzz 0.8 · sym 0.5`, or `nylon` for a bare preset. */
|
|
86
112
|
export function describeString(settings: TrackString | undefined): string {
|
|
87
113
|
const parts = [stringPresetOf(settings)];
|
|
@@ -108,7 +134,7 @@ export function applyStringCommand(
|
|
|
108
134
|
if (command.type === "string-presets")
|
|
109
135
|
return {
|
|
110
136
|
ok: true,
|
|
111
|
-
message:
|
|
137
|
+
message: `${command.bowed ? "bowed" : "string"} presets · ${(command.bowed ? BOWED_PRESET_NAMES : STRING_PRESET_NAMES).map((name) => `${name} (${STRING_PRESETS[name]!.doc})`).join(" · ")}`,
|
|
112
138
|
};
|
|
113
139
|
if (track.sampler || track.kit)
|
|
114
140
|
return {
|