@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
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* glide <ms>|off <target> portamento into each note
|
|
9
9
|
* glide <ms>|0|off [legato|mono|poly] the track's glide (`glide mono`)
|
|
10
10
|
* pedal <beat>-<beat>…|bars [<bar>-<bar>]|down|half|up <beat>|off
|
|
11
|
+
* pedal soft|sost <same forms> una corda and sostenuto (0.6.1)
|
|
11
12
|
* velcurve linear|soft|hard|fixed [<v>]
|
|
12
13
|
* humanize <ms> [<vel%> [<len%>]] [seed <n>]|on|off|reseed|seed <n>
|
|
13
14
|
* humanize <ms> [<vel%> [<len%>]]|exact|off <target> per-note amounts
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
normalizeBend,
|
|
33
34
|
normalizeHumanize,
|
|
34
35
|
normalizePedal,
|
|
36
|
+
SOSTENUTO_STATES,
|
|
35
37
|
normalizeTrackGlide,
|
|
36
38
|
normalizeVelocityCurve,
|
|
37
39
|
normalizeVibrato,
|
|
@@ -53,6 +55,7 @@ import {
|
|
|
53
55
|
type Note,
|
|
54
56
|
type Track,
|
|
55
57
|
} from "../../core/score.ts";
|
|
58
|
+
import { softPedalNote } from "../../core/keys.ts";
|
|
56
59
|
import { barStartTick, loopTicksOf } from "../../core/tempo.ts";
|
|
57
60
|
|
|
58
61
|
export type NoteTarget =
|
|
@@ -73,11 +76,15 @@ export type ExpressionCommand =
|
|
|
73
76
|
| { type: "vibrato"; vibrato: NoteVibrato | null; target: NoteTarget }
|
|
74
77
|
| { type: "note-glide"; glide: number | null; target: NoteTarget }
|
|
75
78
|
| { type: "track-glide"; time?: number | null; mode?: GlideMode }
|
|
76
|
-
| {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
| {
|
|
80
|
+
type: "pedal-spans";
|
|
81
|
+
spans: readonly { from: number; to: number }[];
|
|
82
|
+
lane?: PedalLane;
|
|
83
|
+
}
|
|
84
|
+
| { type: "pedal-bars"; from?: number; to?: number; lane?: PedalLane }
|
|
85
|
+
| { type: "pedal-list"; lane?: PedalLane }
|
|
86
|
+
| { type: "pedal-event"; state: PedalState; beat: number; lane?: PedalLane }
|
|
87
|
+
| { type: "pedal-off"; lane?: PedalLane }
|
|
81
88
|
| { type: "velcurve"; curve: VelocityCurveName; fixed?: number }
|
|
82
89
|
| {
|
|
83
90
|
type: "humanize";
|
|
@@ -299,7 +306,31 @@ function nearWord(text: string, word: string): boolean {
|
|
|
299
306
|
return previous[word.length]! <= 2;
|
|
300
307
|
}
|
|
301
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Which pedal a `pedal` command edits (0.6.1): absent is the sustain
|
|
311
|
+
* pedal, `soft` the una corda (Track.softPedal), `sost` the sostenuto.
|
|
312
|
+
*/
|
|
313
|
+
export type PedalLane = "soft" | "sost";
|
|
314
|
+
|
|
315
|
+
const PEDAL_LANES: Readonly<Record<string, PedalLane>> = {
|
|
316
|
+
soft: "soft",
|
|
317
|
+
unacorda: "soft",
|
|
318
|
+
sost: "sost",
|
|
319
|
+
sostenuto: "sost",
|
|
320
|
+
};
|
|
321
|
+
|
|
302
322
|
function parsePedal(words: string[]): ExpressionCommand | undefined {
|
|
323
|
+
const lane = words[0] ? PEDAL_LANES[words[0]] : undefined;
|
|
324
|
+
if (lane) {
|
|
325
|
+
const command = parsePedalLane(words.slice(1));
|
|
326
|
+
return command && command.type.startsWith("pedal-")
|
|
327
|
+
? ({ ...command, lane } as ExpressionCommand)
|
|
328
|
+
: undefined;
|
|
329
|
+
}
|
|
330
|
+
return parsePedalLane(words);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function parsePedalLane(words: string[]): ExpressionCommand | undefined {
|
|
303
334
|
const [first, ...rest] = words;
|
|
304
335
|
if (!first) return { type: "pedal-list" };
|
|
305
336
|
if (OFF.test(first) && rest.length === 0) return { type: "pedal-off" };
|
|
@@ -564,6 +595,16 @@ export function describePerformance(score: TrackScore, track: Track): string {
|
|
|
564
595
|
const parts = [
|
|
565
596
|
`glide ${track.glide ? `${ms(track.glide.time)} ${track.glide.mode}` : "off"}`,
|
|
566
597
|
`pedal ${track.pedal ? `${track.pedal.length} event${track.pedal.length === 1 ? "" : "s"}` : "off"}`,
|
|
598
|
+
...(track.softPedal
|
|
599
|
+
? [
|
|
600
|
+
`soft ${track.softPedal.length} event${track.softPedal.length === 1 ? "" : "s"}`,
|
|
601
|
+
]
|
|
602
|
+
: []),
|
|
603
|
+
...(track.sostenuto
|
|
604
|
+
? [
|
|
605
|
+
`sost ${track.sostenuto.length} event${track.sostenuto.length === 1 ? "" : "s"}`,
|
|
606
|
+
]
|
|
607
|
+
: []),
|
|
567
608
|
`velcurve ${track.velocityCurve ? `${track.velocityCurve.curve}${track.velocityCurve.fixed !== undefined ? ` ${track.velocityCurve.fixed}` : ""}` : "linear"}`,
|
|
568
609
|
`humanize ${describeHumanize(track.humanize)}`,
|
|
569
610
|
];
|
|
@@ -680,10 +721,24 @@ function mergePedal(
|
|
|
680
721
|
current: readonly PedalEvent[] | undefined,
|
|
681
722
|
added: readonly PedalEvent[],
|
|
682
723
|
maxTick: number,
|
|
724
|
+
label?: string,
|
|
725
|
+
states?: readonly PedalState[],
|
|
683
726
|
): readonly PedalEvent[] | undefined {
|
|
684
727
|
const byTick = new Map((current ?? []).map((event) => [event.tick, event]));
|
|
685
728
|
for (const event of added) byTick.set(event.tick, event);
|
|
686
|
-
return normalizePedal([...byTick.values()], maxTick);
|
|
729
|
+
return normalizePedal([...byTick.values()], maxTick, label, states);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** The Track field, message label and allowed states of a pedal lane. */
|
|
733
|
+
function pedalLaneOf(lane: PedalLane | undefined): {
|
|
734
|
+
field: "pedal" | "softPedal" | "sostenuto";
|
|
735
|
+
label: string;
|
|
736
|
+
states?: readonly PedalState[];
|
|
737
|
+
} {
|
|
738
|
+
if (lane === "soft") return { field: "softPedal", label: "soft pedal" };
|
|
739
|
+
if (lane === "sost")
|
|
740
|
+
return { field: "sostenuto", label: "sostenuto", states: SOSTENUTO_STATES };
|
|
741
|
+
return { field: "pedal", label: "pedal" };
|
|
687
742
|
}
|
|
688
743
|
|
|
689
744
|
/** Apply `command` to the focused track `trackId`. */
|
|
@@ -771,11 +826,12 @@ export function applyExpressionCommand(
|
|
|
771
826
|
);
|
|
772
827
|
}
|
|
773
828
|
case "pedal-list": {
|
|
774
|
-
const
|
|
829
|
+
const { field, label } = pedalLaneOf(command.lane);
|
|
830
|
+
const events = track[field] ?? [];
|
|
775
831
|
if (events.length === 0)
|
|
776
832
|
return {
|
|
777
833
|
ok: true,
|
|
778
|
-
message:
|
|
834
|
+
message: `${label} · none on ${trackId} · ${command.lane ? `pedal ${command.lane} 0-4` : "pedal bars"} to add`,
|
|
779
835
|
};
|
|
780
836
|
const shown = events
|
|
781
837
|
.slice(0, 12)
|
|
@@ -784,24 +840,45 @@ export function applyExpressionCommand(
|
|
|
784
840
|
`${Math.round((event.tick / score.ticksPerBeat) * 1000) / 1000} ${event.state}`,
|
|
785
841
|
);
|
|
786
842
|
if (events.length > 12) shown.push(`+${events.length - 12} more`);
|
|
787
|
-
return {
|
|
843
|
+
return {
|
|
844
|
+
ok: true,
|
|
845
|
+
message: `${label} · ${shown.join(" · ")} · ${trackId}`,
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
case "pedal-off": {
|
|
849
|
+
const { field, label } = pedalLaneOf(command.lane);
|
|
850
|
+
return track[field]
|
|
851
|
+
? trackResult(
|
|
852
|
+
score,
|
|
853
|
+
track,
|
|
854
|
+
{ [field]: null },
|
|
855
|
+
`${label} · off · ${trackId}`,
|
|
856
|
+
)
|
|
857
|
+
: { ok: true, message: `${label} · already off · ${trackId}` };
|
|
788
858
|
}
|
|
789
|
-
case "pedal-off":
|
|
790
|
-
return track.pedal
|
|
791
|
-
? trackResult(score, track, { pedal: null }, `pedal · off · ${trackId}`)
|
|
792
|
-
: { ok: true, message: `pedal · already off · ${trackId}` };
|
|
793
859
|
case "pedal-spans":
|
|
794
860
|
case "pedal-bars":
|
|
795
861
|
case "pedal-event": {
|
|
862
|
+
const { field, label, states } = pedalLaneOf(command.lane);
|
|
796
863
|
let added: PedalEvent[];
|
|
797
864
|
if (command.type === "pedal-bars") {
|
|
798
865
|
const from = command.from ?? 1;
|
|
799
866
|
if (from > score.bars)
|
|
800
867
|
return {
|
|
801
868
|
ok: false,
|
|
802
|
-
message:
|
|
869
|
+
message: `${label} · the loop has ${score.bars} bar${score.bars === 1 ? "" : "s"}`,
|
|
803
870
|
};
|
|
804
|
-
|
|
871
|
+
const to = Math.min(command.to ?? score.bars, score.bars);
|
|
872
|
+
// Soft and sostenuto hold through the bars (no re-pedalling).
|
|
873
|
+
added = command.lane
|
|
874
|
+
? [
|
|
875
|
+
{ tick: barStartTick(score, from - 1), state: "down" },
|
|
876
|
+
{
|
|
877
|
+
tick: Math.min(barStartTick(score, to), maxTick),
|
|
878
|
+
state: "up",
|
|
879
|
+
},
|
|
880
|
+
]
|
|
881
|
+
: barPedal(score, from, to);
|
|
805
882
|
} else if (command.type === "pedal-event") {
|
|
806
883
|
added = [
|
|
807
884
|
{ tick: Math.round(command.beat * tpb), state: command.state },
|
|
@@ -815,7 +892,7 @@ export function applyExpressionCommand(
|
|
|
815
892
|
if (added.some((event) => event.tick > maxTick))
|
|
816
893
|
return {
|
|
817
894
|
ok: false,
|
|
818
|
-
message:
|
|
895
|
+
message: `${label} · beats run 0..${maxTick / tpb} in this loop`,
|
|
819
896
|
};
|
|
820
897
|
let pedal: readonly PedalEvent[] | undefined;
|
|
821
898
|
try {
|
|
@@ -825,19 +902,30 @@ export function applyExpressionCommand(
|
|
|
825
902
|
command.type === "pedal-bars" &&
|
|
826
903
|
command.from === undefined &&
|
|
827
904
|
command.to === undefined
|
|
828
|
-
? normalizePedal(added, maxTick)
|
|
829
|
-
: mergePedal(track
|
|
905
|
+
? normalizePedal(added, maxTick, field, states)
|
|
906
|
+
: mergePedal(track[field], added, maxTick, field, states);
|
|
830
907
|
} catch (error) {
|
|
831
908
|
if (error instanceof ExpressionValidationError)
|
|
832
|
-
return { ok: false, message:
|
|
909
|
+
return { ok: false, message: `${label} · ${error.message}` };
|
|
833
910
|
throw error;
|
|
834
911
|
}
|
|
835
|
-
const downs = (pedal ?? []).filter((event) => event.state
|
|
912
|
+
const downs = (pedal ?? []).filter((event) => event.state === "down");
|
|
913
|
+
const halves = (pedal ?? []).filter((event) => event.state === "half");
|
|
914
|
+
const held = [
|
|
915
|
+
...(downs.length > 0 || halves.length === 0
|
|
916
|
+
? [`${downs.length} down`]
|
|
917
|
+
: []),
|
|
918
|
+
...(halves.length > 0 ? [`${halves.length} half`] : []),
|
|
919
|
+
].join(", ");
|
|
920
|
+
// Una corda is stored anyway (an instrument change may follow), but
|
|
921
|
+
// say when nothing will sound different.
|
|
922
|
+
const silent =
|
|
923
|
+
field === "softPedal" && pedal ? softPedalNote(track) : undefined;
|
|
836
924
|
return trackResult(
|
|
837
925
|
score,
|
|
838
926
|
track,
|
|
839
|
-
{
|
|
840
|
-
|
|
927
|
+
{ [field]: pedal ?? null },
|
|
928
|
+
`${label} · ${pedal?.length ?? 0} event${pedal?.length === 1 ? "" : "s"} (${held}) · ${trackId}${silent ? ` · no effect: ${silent}` : ""}`,
|
|
841
929
|
);
|
|
842
930
|
}
|
|
843
931
|
case "velcurve": {
|
|
@@ -926,7 +1014,7 @@ export const EXPRESSION_USAGE = Object.freeze({
|
|
|
926
1014
|
glide:
|
|
927
1015
|
"glide <ms>|<s>s|off [legato|mono|poly] (track) · glide <ms>|off <target> (notes) · glide 60 mono · SDK glide: 0.06 (s)",
|
|
928
1016
|
pedal:
|
|
929
|
-
"pedal <beat>-<beat>…|bars [<bar>-<bar>]|down|half|up <beat>|off · pedal 0-3.5 4-7.5 · pedal bars",
|
|
1017
|
+
"pedal [soft|sost] <beat>-<beat>…|bars [<bar>-<bar>]|down|half|up <beat>|off · pedal 0-3.5 4-7.5 · pedal bars · pedal soft 0-8 (una corda) · pedal sost 0-4 (holds keys down at 0)",
|
|
930
1018
|
velcurve:
|
|
931
1019
|
"velcurve linear|soft|hard|fixed [<v 0..1>] · velcurve soft · velcurve fixed 0.8",
|
|
932
1020
|
humanize:
|
|
@@ -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
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
* fx list the focused track's effects
|
|
5
5
|
* fx <effect> on|off|reset enable with defaults, remove, reset
|
|
6
6
|
* fx <effect> preset <name> load a named preset (FX_PRESETS)
|
|
7
|
+
* fx <effect> turn it on (same as `on`)
|
|
7
8
|
* fx <effect> <param> <value> set one parameter (enables it)
|
|
8
9
|
* fx <effect> <number> set its first parameter (`fx orbit 2`)
|
|
9
10
|
* fx <effect> <param> <value> <param> <value>… several at once
|
|
10
11
|
* fx reverb ir <impulse>|off convolution reverb (Strudel `ir`):
|
|
11
|
-
* room|hall|plate, a pack sound or a
|
|
12
|
+
* room|hall|plate|reverse|gate|spring, a pack sound or a
|
|
12
13
|
* project file; `fx ir hall` too
|
|
13
14
|
*
|
|
14
15
|
* Effects are every `FX_CHAIN` stage except pan (see core/fx.ts), plus
|
|
@@ -18,6 +19,7 @@
|
|
|
18
19
|
* on/off/true/false; enums take one of their values. Each command is one
|
|
19
20
|
* `updateTrack` revision and one undo step.
|
|
20
21
|
*/
|
|
22
|
+
import { nearestWord } from "../audio/instrument-check.ts";
|
|
21
23
|
import {
|
|
22
24
|
EFFECT_NAMES,
|
|
23
25
|
FX_PRESETS,
|
|
@@ -146,6 +148,8 @@ export function parseFxCommand(prompt: string): FxCommand | undefined {
|
|
|
146
148
|
if (effect === "reverb" && irWord(words[2]) && words.length === 4)
|
|
147
149
|
return irCommand(words[3]!);
|
|
148
150
|
const rest = words.slice(2).map((word) => word.toLowerCase());
|
|
151
|
+
// `fx wobble`, `fx double`: a bare effect name turns it on.
|
|
152
|
+
if (rest.length === 0) return { type: "fx-on", effect };
|
|
149
153
|
if (rest.length === 1 && (rest[0] === "on" || rest[0] === "off"))
|
|
150
154
|
return { type: rest[0] === "on" ? "fx-on" : "fx-off", effect };
|
|
151
155
|
if (rest.length === 1 && rest[0] === "reset")
|
|
@@ -190,6 +194,31 @@ export function parseFxCommand(prompt: string): FxCommand | undefined {
|
|
|
190
194
|
return { type: "fx-set", effect, values };
|
|
191
195
|
}
|
|
192
196
|
|
|
197
|
+
/**
|
|
198
|
+
* A short `fx <word> …` whose word is no effect (`fx wobble on`, `fx dela
|
|
199
|
+
* mix 0.3`): the local answer, so a typo never goes to the agent. Longer
|
|
200
|
+
* free text after `fx` still does.
|
|
201
|
+
*/
|
|
202
|
+
export function unknownFxMessage(prompt: string): string | undefined {
|
|
203
|
+
const words = prompt.trim().split(/\s+/);
|
|
204
|
+
if (words[0]?.toLowerCase() !== "fx" || words.length < 2) return undefined;
|
|
205
|
+
const name = words[1]!.toLowerCase();
|
|
206
|
+
if (parseEffectName(name) || ["ir", "iresponse", "amp"].includes(name))
|
|
207
|
+
return undefined;
|
|
208
|
+
const near = nearestWord(name, [
|
|
209
|
+
...EFFECT_NAMES,
|
|
210
|
+
...Object.keys(EFFECT_ALIASES),
|
|
211
|
+
"reverb",
|
|
212
|
+
]);
|
|
213
|
+
const rest = words.slice(2).map((word) => word.toLowerCase());
|
|
214
|
+
const commandShaped =
|
|
215
|
+
rest.length <= 1 ||
|
|
216
|
+
["on", "off", "reset", "preset"].includes(rest[0]!) ||
|
|
217
|
+
rest.every((word, index) => index % 2 === 0 || /^-?[\d.]+/.test(word));
|
|
218
|
+
if (!near && !commandShaped) return undefined;
|
|
219
|
+
return `unknown effect ${name.slice(0, 24)}${near ? ` · did you mean ${near}?` : ""} · effects ${EFFECT_NAMES.join(", ")}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
193
222
|
function irCommand(value: string): FxCommand | undefined {
|
|
194
223
|
const lower = value.toLowerCase();
|
|
195
224
|
if (lower === "off" || lower === "none") return { type: "fx-ir", ir: null };
|
|
@@ -208,6 +237,11 @@ export function effectDefaults(effect: EffectName): FxValues {
|
|
|
208
237
|
if (!(spec.optional && spec.default === false)) out[key] = spec.default;
|
|
209
238
|
// A zero `time` means "follow beats"; the canonical form omits it.
|
|
210
239
|
if (effect === "delay") delete out.time;
|
|
240
|
+
// A head's sag follows its type and its noise gate is off until set.
|
|
241
|
+
if (effect === "head") {
|
|
242
|
+
delete out.sag;
|
|
243
|
+
delete out.gate;
|
|
244
|
+
}
|
|
211
245
|
return out;
|
|
212
246
|
}
|
|
213
247
|
|