@hraness/dawg 0.5.0 → 0.6.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 +29 -0
- package/DAWG.md +269 -13
- package/README.md +4 -4
- package/core/diff.ts +4 -0
- package/core/fx.ts +437 -2
- package/core/granular.ts +528 -0
- package/core/instruments.ts +281 -0
- package/core/keys.ts +386 -0
- package/core/resonators.ts +574 -0
- package/core/score.ts +181 -6
- package/core/sdk/eval-child.ts +2 -0
- package/core/sdk/print.ts +169 -13
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +1114 -21
- package/core/sections.ts +34 -8
- package/core/strings.ts +845 -0
- package/guides/effects.md +3 -1
- package/guides/sounds.md +4 -0
- package/guides/tempo.md +1 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +7 -0
- package/src/agent/granular-tools.ts +138 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +39 -2
- package/src/agent/preview-tool.ts +6 -1
- package/src/agent/tools.ts +538 -14
- package/src/agent/xcb-agent.ts +7 -0
- package/src/audio/arrange.ts +21 -3
- package/src/audio/dsp/bank.ts +233 -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/stft.ts +73 -0
- package/src/audio/dsp/window.ts +77 -0
- package/src/audio/effects/chain.ts +6 -1
- 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 +10 -1
- package/src/audio/fit.ts +402 -0
- package/src/audio/granular.ts +664 -0
- package/src/audio/instrument-check.ts +133 -0
- package/src/audio/instruments.ts +111 -0
- package/src/audio/keys/dsp.ts +323 -0
- package/src/audio/keys/engine.ts +361 -0
- package/src/audio/keys/piano.ts +432 -0
- package/src/audio/live-worker.ts +54 -0
- package/src/audio/live.ts +231 -12
- package/src/audio/loudness.ts +65 -20
- package/src/audio/preview.ts +2 -0
- package/src/audio/resonators.ts +287 -0
- package/src/audio/sampler.ts +163 -17
- package/src/audio/samples.ts +30 -7
- package/src/audio/strings/body.ts +250 -0
- package/src/audio/strings/engine.ts +369 -0
- package/src/audio/strings/loop.ts +119 -0
- package/src/audio/strings/measure.test-helpers.ts +198 -0
- package/src/audio/strings/pluck.ts +354 -0
- package/src/audio/warp.ts +61 -0
- package/src/audio/wav.ts +78 -6
- package/src/commands/expression.ts +9 -2
- package/src/commands/fit.ts +135 -0
- package/src/commands/fx.ts +31 -0
- package/src/commands/granular.ts +427 -0
- package/src/commands/help.ts +74 -3
- package/src/commands/keys.ts +353 -0
- package/src/commands/modal.ts +247 -0
- package/src/commands/rig.ts +260 -0
- package/src/commands/sample.ts +3 -0
- package/src/commands/string.ts +175 -0
- package/src/commands/time.ts +4 -1
- package/src/main.ts +210 -8
- package/src/project/check.ts +13 -0
- package/src/render.ts +33 -4
- package/src/tui/audition.ts +2 -2
- package/src/tui/granular-menu.ts +278 -0
- package/src/tui/menu.ts +332 -9
- package/src/tui/modal-menu.ts +118 -0
- package/src/tui/play-session.ts +45 -2
package/src/audio/warp.ts
CHANGED
|
@@ -84,3 +84,64 @@ export function warpedSpan(
|
|
|
84
84
|
length: Math.max(1, Math.floor(to - from)),
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A fitted sample window's position map (0.6 `bpm`/`len`/`fit`): output
|
|
90
|
+
* frames after the note start to window frames and back, both at the
|
|
91
|
+
* render rate. Through a tempo map the window follows ramps and steps;
|
|
92
|
+
* at constant tempo both directions are linear.
|
|
93
|
+
*/
|
|
94
|
+
export type FitMap = Readonly<{
|
|
95
|
+
/** Output frames the whole window takes. */
|
|
96
|
+
length: number;
|
|
97
|
+
/** Window frame sounding `e` output frames after the start. */
|
|
98
|
+
at(e: number): number;
|
|
99
|
+
/** Output frame (after the start) where window frame `p` sounds. */
|
|
100
|
+
outAt(p: number): number;
|
|
101
|
+
}>;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The map for a window of `frames` render-rate frames that advances
|
|
105
|
+
* `perBeat` window frames per song beat from `startTick`. Without a warp the
|
|
106
|
+
* song runs at `tempoBpm` throughout.
|
|
107
|
+
*/
|
|
108
|
+
export function beatFitMap(
|
|
109
|
+
warp: SampleWarp | undefined,
|
|
110
|
+
timing: Readonly<{
|
|
111
|
+
tempoBpm: number;
|
|
112
|
+
ticksPerBeat: number;
|
|
113
|
+
sampleRate: number;
|
|
114
|
+
}>,
|
|
115
|
+
startTick: number,
|
|
116
|
+
frames: number,
|
|
117
|
+
perBeat: number,
|
|
118
|
+
): FitMap {
|
|
119
|
+
const { tempoBpm, ticksPerBeat, sampleRate } = timing;
|
|
120
|
+
if (!warp) {
|
|
121
|
+
const ratio = perBeat / ((sampleRate * 60) / tempoBpm);
|
|
122
|
+
return Object.freeze({
|
|
123
|
+
length: frames / ratio,
|
|
124
|
+
at: (e: number) => e * ratio,
|
|
125
|
+
outAt: (p: number) => p / ratio,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
const origin = warp.sample(startTick);
|
|
129
|
+
const outAt = (p: number) =>
|
|
130
|
+
warp.sample(startTick + (p / perBeat) * ticksPerBeat) - origin;
|
|
131
|
+
return Object.freeze({
|
|
132
|
+
length: outAt(frames),
|
|
133
|
+
at: (e: number) =>
|
|
134
|
+
((warp.tick(origin + e) - startTick) / ticksPerBeat) * perBeat,
|
|
135
|
+
outAt,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** `fit`: the window fills `held` output frames, linearly. */
|
|
140
|
+
export function spanFitMap(frames: number, held: number): FitMap {
|
|
141
|
+
const ratio = frames / Math.max(1, held);
|
|
142
|
+
return Object.freeze({
|
|
143
|
+
length: held,
|
|
144
|
+
at: (e: number) => e * ratio,
|
|
145
|
+
outAt: (p: number) => p / ratio,
|
|
146
|
+
});
|
|
147
|
+
}
|
package/src/audio/wav.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from "./sampler.ts";
|
|
24
24
|
import { EFFECT_NAMES, FX_PRESETS, effectSpec } from "../../core/fx.ts";
|
|
25
25
|
import { seededRandom } from "./random.ts";
|
|
26
|
+
import { engineFor, engineTailSeconds } from "./instruments.ts";
|
|
26
27
|
import {
|
|
27
28
|
isStereoVoice,
|
|
28
29
|
renderSynthNote,
|
|
@@ -139,6 +140,9 @@ export const AVAILABLE_INSTRUMENTS = Object.freeze([
|
|
|
139
140
|
"z_square",
|
|
140
141
|
"z_tan",
|
|
141
142
|
"z_noise",
|
|
143
|
+
// 0.6 instruments (src/audio/instruments.ts), one line per lane.
|
|
144
|
+
"granular",
|
|
145
|
+
"modal",
|
|
142
146
|
] as const);
|
|
143
147
|
|
|
144
148
|
/** Per-track effects understood by the renderer and the agent, in chain
|
|
@@ -180,8 +184,32 @@ function oneShotSeconds(
|
|
|
180
184
|
}
|
|
181
185
|
/** Loop renders fold at most this much tail back onto the loop start. */
|
|
182
186
|
const MAX_LOOP_TAIL_SECONDS = 8;
|
|
187
|
+
/** A 0.6 engine's one-shot ring-out is capped here, not at the loop fold. */
|
|
188
|
+
const MAX_ENGINE_TAIL_SECONDS = 30;
|
|
183
189
|
|
|
184
|
-
|
|
190
|
+
/**
|
|
191
|
+
* The longest 0.6 engine ring-out in `score`, capped at 30 s. One-shot
|
|
192
|
+
* renders (exports, previews) let engine tails reach their own cap; the
|
|
193
|
+
* 8 s cap applies only to loop folding. Zero without an engine, so legacy
|
|
194
|
+
* renders are unchanged.
|
|
195
|
+
*/
|
|
196
|
+
function oneShotEngineTail(score: TrackScore): number {
|
|
197
|
+
let tail = 0;
|
|
198
|
+
for (const track of score.tracks) {
|
|
199
|
+
if (!engineFor(track)) continue;
|
|
200
|
+
let lowest: number | undefined;
|
|
201
|
+
for (const note of score.notes)
|
|
202
|
+
if (
|
|
203
|
+
note.trackId === track.id &&
|
|
204
|
+
(lowest === undefined || note.pitch < lowest)
|
|
205
|
+
)
|
|
206
|
+
lowest = note.pitch;
|
|
207
|
+
tail = Math.max(tail, engineTailSeconds(track, lowest));
|
|
208
|
+
}
|
|
209
|
+
return Math.min(MAX_ENGINE_TAIL_SECONDS, tail);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export type RenderContext = Readonly<{
|
|
185
213
|
score: TrackScore;
|
|
186
214
|
sampleRate: number;
|
|
187
215
|
samples: number;
|
|
@@ -314,9 +342,15 @@ export class StemRenderer {
|
|
|
314
342
|
Math.max(
|
|
315
343
|
samplerTailSeconds(score, bank, sampleRate),
|
|
316
344
|
...score.tracks.map(synthTailSeconds),
|
|
345
|
+
// Zero without a registered 0.6 instrument engine.
|
|
346
|
+
...score.tracks.map((track) => engineTailSeconds(track)),
|
|
317
347
|
),
|
|
318
348
|
);
|
|
319
|
-
return oneShotSeconds(
|
|
349
|
+
return oneShotSeconds(
|
|
350
|
+
loopSecondsOf(score),
|
|
351
|
+
Math.max(samplerTail, oneShotEngineTail(score)),
|
|
352
|
+
reverbTail,
|
|
353
|
+
);
|
|
320
354
|
}
|
|
321
355
|
|
|
322
356
|
public render(score: TrackScore, options: RenderOptions = {}): RenderedAudio {
|
|
@@ -338,6 +372,8 @@ export class StemRenderer {
|
|
|
338
372
|
Math.max(
|
|
339
373
|
samplerTailSeconds(score, bank, sampleRate),
|
|
340
374
|
...score.tracks.map(synthTailSeconds),
|
|
375
|
+
// Zero without a registered 0.6 instrument engine.
|
|
376
|
+
...score.tracks.map((track) => engineTailSeconds(track)),
|
|
341
377
|
),
|
|
342
378
|
);
|
|
343
379
|
let frames: number;
|
|
@@ -367,7 +403,11 @@ export class StemRenderer {
|
|
|
367
403
|
} else {
|
|
368
404
|
const seconds = Math.min(
|
|
369
405
|
maxSeconds,
|
|
370
|
-
oneShotSeconds(
|
|
406
|
+
oneShotSeconds(
|
|
407
|
+
loopSeconds,
|
|
408
|
+
Math.max(samplerTail, oneShotEngineTail(score)),
|
|
409
|
+
reverbTail,
|
|
410
|
+
),
|
|
371
411
|
);
|
|
372
412
|
frames = Math.max(1, Math.ceil(seconds * sampleRate));
|
|
373
413
|
samples = frames;
|
|
@@ -437,6 +477,18 @@ export class StemRenderer {
|
|
|
437
477
|
const played = performed.get(trackId)!;
|
|
438
478
|
const busOrbit = sharedOrbitOf(track);
|
|
439
479
|
const sampler = isSamplerInstrument(track?.instrument);
|
|
480
|
+
// A 0.6 instrument engine (src/audio/instruments.ts), if registered
|
|
481
|
+
// and the track carries its field; undefined keeps today's voices.
|
|
482
|
+
const engine = engineFor(track);
|
|
483
|
+
// The song key always joins an engine's stem key: engines may tune to
|
|
484
|
+
// it (sympathetic strings) without a song tuning.
|
|
485
|
+
const engineDigests =
|
|
486
|
+
engine && track
|
|
487
|
+
? [
|
|
488
|
+
...(engine.assetDigests?.(track, bank) ?? []),
|
|
489
|
+
`key:${score.key ?? ""}`,
|
|
490
|
+
]
|
|
491
|
+
: undefined;
|
|
440
492
|
// Wavetable hook: the oscillator factory for a wavetable track (its
|
|
441
493
|
// table id joins the stem key), undefined for every other instrument.
|
|
442
494
|
const wavetable = track ? wavetableHook(track, bank, context) : undefined;
|
|
@@ -449,6 +501,7 @@ export class StemRenderer {
|
|
|
449
501
|
sampler ? bank : undefined,
|
|
450
502
|
wavetable?.id,
|
|
451
503
|
track ? reverbImpulse(track, sampleRate, bank.irs)?.id : undefined,
|
|
504
|
+
engineDigests,
|
|
452
505
|
);
|
|
453
506
|
let stem = this.stems.get(trackId);
|
|
454
507
|
const caching = this.maxCacheBytes > 0;
|
|
@@ -464,6 +517,7 @@ export class StemRenderer {
|
|
|
464
517
|
sampler ? bank : undefined,
|
|
465
518
|
wavetable?.id,
|
|
466
519
|
reverbImpulse(track, sampleRate, bank.irs)?.id,
|
|
520
|
+
engineDigests,
|
|
467
521
|
)
|
|
468
522
|
: undefined;
|
|
469
523
|
if (stem?.key === key) stem.used = this.renders;
|
|
@@ -490,10 +544,25 @@ export class StemRenderer {
|
|
|
490
544
|
}
|
|
491
545
|
: { left, right };
|
|
492
546
|
dry.fill(0);
|
|
493
|
-
const synthVoice = !sampler && usesSynthVoice(track);
|
|
494
|
-
const stereo =
|
|
547
|
+
const synthVoice = !engine && !sampler && usesSynthVoice(track);
|
|
548
|
+
const stereo = engine
|
|
549
|
+
? engine.stereo(track!)
|
|
550
|
+
: synthVoice && isStereoVoice(track);
|
|
495
551
|
if (stereo) dryR.fill(0);
|
|
496
|
-
if (
|
|
552
|
+
if (engine && track) {
|
|
553
|
+
engine.render(
|
|
554
|
+
dry,
|
|
555
|
+
stereo ? dryR : undefined,
|
|
556
|
+
played,
|
|
557
|
+
track,
|
|
558
|
+
{
|
|
559
|
+
...context,
|
|
560
|
+
ticksPerBeat: score.ticksPerBeat,
|
|
561
|
+
...(tuning ? { tuning } : {}),
|
|
562
|
+
},
|
|
563
|
+
bank,
|
|
564
|
+
);
|
|
565
|
+
} else if (sampler) {
|
|
497
566
|
if (track) renderSamplerNotes(dry, played, track, context, bank);
|
|
498
567
|
} else if (synthVoice && track) {
|
|
499
568
|
const gainAt = (tick: number) => trackGainAt(track, tick);
|
|
@@ -826,6 +895,7 @@ function stemKey(
|
|
|
826
895
|
bank?: SampleBank,
|
|
827
896
|
wavetableId?: string,
|
|
828
897
|
impulseId?: string,
|
|
898
|
+
engineDigests?: readonly string[],
|
|
829
899
|
): string {
|
|
830
900
|
let settings: Record<string, unknown> | null = null;
|
|
831
901
|
if (track) {
|
|
@@ -849,6 +919,8 @@ function stemKey(
|
|
|
849
919
|
...(wavetableId ? [wavetableId] : []),
|
|
850
920
|
// A convolution reverb's impulse (its sha256 for a sample).
|
|
851
921
|
...(impulseId ? [impulseId] : []),
|
|
922
|
+
// Assets a 0.6 instrument engine reads (absent without an engine).
|
|
923
|
+
...(engineDigests ? [engineDigests] : []),
|
|
852
924
|
// The song tuning, and the key whose tonic is the default root.
|
|
853
925
|
...(context.score.tuning || track?.tuning
|
|
854
926
|
? [context.score.tuning ?? null, context.score.key]
|
|
@@ -832,12 +832,19 @@ export function applyExpressionCommand(
|
|
|
832
832
|
return { ok: false, message: `pedal · ${error.message}` };
|
|
833
833
|
throw error;
|
|
834
834
|
}
|
|
835
|
-
const downs = (pedal ?? []).filter((event) => event.state
|
|
835
|
+
const downs = (pedal ?? []).filter((event) => event.state === "down");
|
|
836
|
+
const halves = (pedal ?? []).filter((event) => event.state === "half");
|
|
837
|
+
const held = [
|
|
838
|
+
...(downs.length > 0 || halves.length === 0
|
|
839
|
+
? [`${downs.length} down`]
|
|
840
|
+
: []),
|
|
841
|
+
...(halves.length > 0 ? [`${halves.length} half`] : []),
|
|
842
|
+
].join(", ");
|
|
836
843
|
return trackResult(
|
|
837
844
|
score,
|
|
838
845
|
track,
|
|
839
846
|
{ pedal: pedal ?? null },
|
|
840
|
-
`pedal · ${pedal?.length ?? 0} event${pedal?.length === 1 ? "" : "s"} (${
|
|
847
|
+
`pedal · ${pedal?.length ?? 0} event${pedal?.length === 1 ? "" : "s"} (${held}) · ${trackId}`,
|
|
841
848
|
);
|
|
842
849
|
}
|
|
843
850
|
case "velcurve": {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/fitmode <mode>`, `/bpm <n>` and `/len <beats>` (0.6): fit a sampler
|
|
3
|
+
* voice to the song's time. They act on the focused sampler track's voice
|
|
4
|
+
* (named, or its only voice) through `setSampleControls`, so validation and
|
|
5
|
+
* printing stay the sampler's. `/fitmode` with no mode suggests one from
|
|
6
|
+
* the sample's content (`suggestFitMode`); song tempo stays `tempo <n>`.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
SAMPLE_FIT_MODES,
|
|
10
|
+
isSamplerInstrument,
|
|
11
|
+
samplerVoiceSlots,
|
|
12
|
+
type SampleFitMode,
|
|
13
|
+
type TrackScore,
|
|
14
|
+
} from "../../core/score.ts";
|
|
15
|
+
import { setSampleControls, type SetSampleResult } from "./sample.ts";
|
|
16
|
+
|
|
17
|
+
export type FitCommand = Readonly<{
|
|
18
|
+
control: "fitmode" | "bpm" | "len";
|
|
19
|
+
/** Undefined: `/fitmode` alone (suggest) or `off`-style unset is null. */
|
|
20
|
+
value: SampleFitMode | number | null | undefined;
|
|
21
|
+
voice?: string;
|
|
22
|
+
}>;
|
|
23
|
+
|
|
24
|
+
const VOICE = "([a-z][a-z0-9_]{0,31})";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse `/fitmode [mode|auto|off [voice]]`, `/bpm <n|off> [voice]`,
|
|
28
|
+
* `/len <beats|off> [voice]`. `fitmode` and `len` also work without the
|
|
29
|
+
* slash; a bare `bpm 120` stays the song's tempo word, so `/bpm` needs it.
|
|
30
|
+
*/
|
|
31
|
+
export function parseFitCommand(command: string): FitCommand | undefined {
|
|
32
|
+
const text = command.trim();
|
|
33
|
+
const mode = text.match(
|
|
34
|
+
new RegExp(
|
|
35
|
+
`^/?fitmode(?:\\s+(repitch|beats|tones|auto|off|none)(?:\\s+${VOICE})?)?$`,
|
|
36
|
+
"i",
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
if (mode) {
|
|
40
|
+
const raw = mode[1]?.toLowerCase();
|
|
41
|
+
return {
|
|
42
|
+
control: "fitmode",
|
|
43
|
+
value:
|
|
44
|
+
raw === undefined || raw === "auto"
|
|
45
|
+
? undefined
|
|
46
|
+
: raw === "off" || raw === "none"
|
|
47
|
+
? null
|
|
48
|
+
: (raw as SampleFitMode),
|
|
49
|
+
...(mode[2] ? { voice: mode[2].toLowerCase() } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const number = text.match(
|
|
53
|
+
new RegExp(
|
|
54
|
+
`^(/bpm|/?len)\\s+(\\d+(?:\\.\\d+)?|off|none)(?:\\s+${VOICE})?$`,
|
|
55
|
+
"i",
|
|
56
|
+
),
|
|
57
|
+
);
|
|
58
|
+
if (!number) return undefined;
|
|
59
|
+
const raw = number[2]!.toLowerCase();
|
|
60
|
+
return {
|
|
61
|
+
control: number[1]!.toLowerCase().replace("/", "") as "bpm" | "len",
|
|
62
|
+
value: raw === "off" || raw === "none" ? null : Number(raw),
|
|
63
|
+
...(number[3] ? { voice: number[3].toLowerCase() } : {}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The voice a fit command acts on: the named one, else the track's only
|
|
69
|
+
* voice (lowest slot first when it has several and none is named).
|
|
70
|
+
*/
|
|
71
|
+
export function fitVoice(
|
|
72
|
+
score: TrackScore,
|
|
73
|
+
trackId: string,
|
|
74
|
+
voice: string | undefined,
|
|
75
|
+
): { voice: string } | { error: string } {
|
|
76
|
+
const track = score.tracks.find((item) => item.id === trackId);
|
|
77
|
+
const sampler = track?.sampler;
|
|
78
|
+
if (!track || !sampler || !isSamplerInstrument(track.instrument))
|
|
79
|
+
return {
|
|
80
|
+
error: `fit · ${trackId} is not a sampler track · /sample <path> first · song tempo is tempo <bpm>`,
|
|
81
|
+
};
|
|
82
|
+
if (voice) return { voice };
|
|
83
|
+
const names = Object.keys(sampler.voices);
|
|
84
|
+
if (names.length === 1) return { voice: names[0]! };
|
|
85
|
+
const slots = samplerVoiceSlots(sampler);
|
|
86
|
+
const sorted = names.sort(
|
|
87
|
+
(a, b) => (slots.get(a) ?? 0) - (slots.get(b) ?? 0) || (a < b ? -1 : 1),
|
|
88
|
+
);
|
|
89
|
+
return {
|
|
90
|
+
error: `fit · ${trackId} has ${names.length} voices · name one: ${sorted.join(" ")}`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Apply a parsed fit command with a known value (a suggested mode resolved). */
|
|
95
|
+
export function applyFitCommand(
|
|
96
|
+
score: TrackScore,
|
|
97
|
+
trackId: string,
|
|
98
|
+
command: FitCommand,
|
|
99
|
+
mode?: SampleFitMode,
|
|
100
|
+
): SetSampleResult {
|
|
101
|
+
const target = fitVoice(score, trackId, command.voice);
|
|
102
|
+
if ("error" in target) return { ok: false, message: target.error };
|
|
103
|
+
const value = command.value === undefined ? (mode ?? null) : command.value;
|
|
104
|
+
if (command.control === "fitmode" && value !== null) {
|
|
105
|
+
const ref = score.tracks.find((t) => t.id === trackId)?.sampler?.voices[
|
|
106
|
+
target.voice
|
|
107
|
+
];
|
|
108
|
+
if (
|
|
109
|
+
ref &&
|
|
110
|
+
value !== "repitch" &&
|
|
111
|
+
ref.bpm === undefined &&
|
|
112
|
+
ref.len === undefined &&
|
|
113
|
+
ref.fit !== true
|
|
114
|
+
)
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
message: `fit · fitmode ${String(value)} needs the sample's tempo first · /bpm <n> or /len <beats>`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const values: Record<string, number | string | null> = {
|
|
121
|
+
[command.control]: value,
|
|
122
|
+
};
|
|
123
|
+
// Unsetting bpm or len leaves a fitmode with nothing to fit: unset both.
|
|
124
|
+
if (value === null && command.control !== "fitmode") {
|
|
125
|
+
const ref = score.tracks.find((t) => t.id === trackId)?.sampler?.voices[
|
|
126
|
+
target.voice
|
|
127
|
+
];
|
|
128
|
+
const other = command.control === "bpm" ? ref?.len : ref?.bpm;
|
|
129
|
+
if (ref?.fitmode && other === undefined && ref.fit !== true)
|
|
130
|
+
values.fitmode = null;
|
|
131
|
+
}
|
|
132
|
+
return setSampleControls(score, trackId, target.voice, values);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export const FIT_MODES = SAMPLE_FIT_MODES;
|
package/src/commands/fx.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* on/off/true/false; enums take one of their values. Each command is one
|
|
19
19
|
* `updateTrack` revision and one undo step.
|
|
20
20
|
*/
|
|
21
|
+
import { nearestWord } from "../audio/instrument-check.ts";
|
|
21
22
|
import {
|
|
22
23
|
EFFECT_NAMES,
|
|
23
24
|
FX_PRESETS,
|
|
@@ -190,6 +191,31 @@ export function parseFxCommand(prompt: string): FxCommand | undefined {
|
|
|
190
191
|
return { type: "fx-set", effect, values };
|
|
191
192
|
}
|
|
192
193
|
|
|
194
|
+
/**
|
|
195
|
+
* A short `fx <word> …` whose word is no effect (`fx wobble on`, `fx dela
|
|
196
|
+
* mix 0.3`): the local answer, so a typo never goes to the agent. Longer
|
|
197
|
+
* free text after `fx` still does.
|
|
198
|
+
*/
|
|
199
|
+
export function unknownFxMessage(prompt: string): string | undefined {
|
|
200
|
+
const words = prompt.trim().split(/\s+/);
|
|
201
|
+
if (words[0]?.toLowerCase() !== "fx" || words.length < 2) return undefined;
|
|
202
|
+
const name = words[1]!.toLowerCase();
|
|
203
|
+
if (parseEffectName(name) || ["ir", "iresponse", "amp"].includes(name))
|
|
204
|
+
return undefined;
|
|
205
|
+
const near = nearestWord(name, [
|
|
206
|
+
...EFFECT_NAMES,
|
|
207
|
+
...Object.keys(EFFECT_ALIASES),
|
|
208
|
+
"reverb",
|
|
209
|
+
]);
|
|
210
|
+
const rest = words.slice(2).map((word) => word.toLowerCase());
|
|
211
|
+
const commandShaped =
|
|
212
|
+
rest.length <= 1 ||
|
|
213
|
+
["on", "off", "reset", "preset"].includes(rest[0]!) ||
|
|
214
|
+
rest.every((word, index) => index % 2 === 0 || /^-?[\d.]+/.test(word));
|
|
215
|
+
if (!near && !commandShaped) return undefined;
|
|
216
|
+
return `unknown effect ${name.slice(0, 24)}${near ? ` · did you mean ${near}?` : ""} · effects ${EFFECT_NAMES.join(", ")}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
193
219
|
function irCommand(value: string): FxCommand | undefined {
|
|
194
220
|
const lower = value.toLowerCase();
|
|
195
221
|
if (lower === "off" || lower === "none") return { type: "fx-ir", ir: null };
|
|
@@ -208,6 +234,11 @@ export function effectDefaults(effect: EffectName): FxValues {
|
|
|
208
234
|
if (!(spec.optional && spec.default === false)) out[key] = spec.default;
|
|
209
235
|
// A zero `time` means "follow beats"; the canonical form omits it.
|
|
210
236
|
if (effect === "delay") delete out.time;
|
|
237
|
+
// A head's sag follows its type and its noise gate is off until set.
|
|
238
|
+
if (effect === "head") {
|
|
239
|
+
delete out.sag;
|
|
240
|
+
delete out.gate;
|
|
241
|
+
}
|
|
211
242
|
return out;
|
|
212
243
|
}
|
|
213
244
|
|