@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/tui/menu.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { HINTS } from "../../tui/grammar.ts";
|
|
|
13
13
|
import { arrangeDetail, arrangeNodes } from "./arrange-menu.ts";
|
|
14
14
|
import { auditionKey, isStageable, type AuditionKey } from "./audition.ts";
|
|
15
15
|
import { performanceDetail, performanceNodes } from "./performance-menu.ts";
|
|
16
|
+
import { malletsMenu, modalParameterNodes } from "./modal-menu.ts";
|
|
16
17
|
import type { FaderSpec } from "./fader.ts";
|
|
17
18
|
import {
|
|
18
19
|
openingMeterCommand,
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
automationPoints,
|
|
26
27
|
automationRange,
|
|
27
28
|
REVERB_IR_BUILTINS,
|
|
29
|
+
SAMPLE_FIT_MODES,
|
|
28
30
|
SAMPLE_UNITS,
|
|
29
31
|
SCORE_LIMITS,
|
|
30
32
|
isTrackAutomationParameter,
|
|
@@ -42,6 +44,9 @@ import {
|
|
|
42
44
|
CORE_EFFECTS,
|
|
43
45
|
EFFECT_NAMES,
|
|
44
46
|
FX_LANES,
|
|
47
|
+
RIG_PRESETS,
|
|
48
|
+
RIG_STAGES,
|
|
49
|
+
rigPresetOf,
|
|
45
50
|
effectPresetNames,
|
|
46
51
|
effectSpec,
|
|
47
52
|
type EffectName,
|
|
@@ -85,6 +90,22 @@ import {
|
|
|
85
90
|
SYNTH_SIMPLE,
|
|
86
91
|
normalizeSynth,
|
|
87
92
|
} from "../../core/synth.ts";
|
|
93
|
+
import {
|
|
94
|
+
isStringTrack,
|
|
95
|
+
STRING_PARAMS,
|
|
96
|
+
STRING_PRESET_NAMES,
|
|
97
|
+
STRING_PRESETS,
|
|
98
|
+
STRING_SIMPLE_PARAMS,
|
|
99
|
+
stringPresetOf,
|
|
100
|
+
} from "../../core/strings.ts";
|
|
101
|
+
import {
|
|
102
|
+
KEYS_PARAMS,
|
|
103
|
+
KEYS_PRESETS,
|
|
104
|
+
KEYS_SIMPLE,
|
|
105
|
+
PIANO_FAMILIES,
|
|
106
|
+
isPianoFamily,
|
|
107
|
+
resolvedKeys,
|
|
108
|
+
} from "../../core/keys.ts";
|
|
88
109
|
import type { PickerItem } from "../../tui/app.ts";
|
|
89
110
|
import { BUILTIN_TABLES, BUILTIN_TABLE_NAMES } from "../audio/wavetable.ts";
|
|
90
111
|
import {
|
|
@@ -114,6 +135,12 @@ import {
|
|
|
114
135
|
type Tuning,
|
|
115
136
|
} from "../../core/tuning.ts";
|
|
116
137
|
import { rootName, tuningStepsText } from "../commands/tuning.ts";
|
|
138
|
+
import {
|
|
139
|
+
granularBrowseNodes,
|
|
140
|
+
granularMenuDetail,
|
|
141
|
+
granularMenuLabel,
|
|
142
|
+
granularMenuNodes,
|
|
143
|
+
} from "./granular-menu.ts";
|
|
117
144
|
import {
|
|
118
145
|
ARP_RATES,
|
|
119
146
|
CHORD_MODES,
|
|
@@ -321,7 +348,7 @@ const TRACK_LANE_LABEL: Readonly<Record<TrackAutomationParameter, string>> = {
|
|
|
321
348
|
const FX_LANE_INFO = new Map(FX_LANES.map((entry) => [entry.lane, entry]));
|
|
322
349
|
|
|
323
350
|
/** Nudge for a number spec: linear by its step, or a sixth of an octave. */
|
|
324
|
-
function specStep(
|
|
351
|
+
export function specStep(
|
|
325
352
|
spec: NumberParam,
|
|
326
353
|
): (value: number, direction: 1 | -1) => number {
|
|
327
354
|
if (spec.step === "log")
|
|
@@ -354,7 +381,12 @@ function laneLabel(lane: AutomationParameter): string {
|
|
|
354
381
|
if (!info) return lane;
|
|
355
382
|
const unit = info.spec.unit ? ` (${info.spec.unit})` : "";
|
|
356
383
|
const owner =
|
|
357
|
-
info.effect === "synth"
|
|
384
|
+
info.effect === "synth" ||
|
|
385
|
+
info.effect === "string" ||
|
|
386
|
+
info.effect === "keys" ||
|
|
387
|
+
info.effect === "modal"
|
|
388
|
+
? info.effect
|
|
389
|
+
: effectSpec(info.effect).label;
|
|
358
390
|
return `${owner} ${info.param}${unit}`;
|
|
359
391
|
}
|
|
360
392
|
|
|
@@ -486,8 +518,42 @@ function soundSectionNodes(context: MenuContext): MenuNode[] {
|
|
|
486
518
|
},
|
|
487
519
|
]
|
|
488
520
|
: [];
|
|
521
|
+
const grainLabel = granularMenuLabel(track);
|
|
522
|
+
const granular: MenuNode[] =
|
|
523
|
+
track && grainLabel
|
|
524
|
+
? [
|
|
525
|
+
{
|
|
526
|
+
kind: "menu",
|
|
527
|
+
id: "granular",
|
|
528
|
+
label: grainLabel,
|
|
529
|
+
detail: granularMenuDetail(track),
|
|
530
|
+
help: "grain clouds: preset, source, position, scan, grain size, pitch, shimmer",
|
|
531
|
+
build: (inner) => granularMenuNodes(focused(inner)),
|
|
532
|
+
},
|
|
533
|
+
]
|
|
534
|
+
: [];
|
|
535
|
+
const keys: MenuNode[] =
|
|
536
|
+
track && isPianoFamily(track.instrument) && track.keys
|
|
537
|
+
? [
|
|
538
|
+
{
|
|
539
|
+
kind: "menu",
|
|
540
|
+
id: "keys",
|
|
541
|
+
label: "keys",
|
|
542
|
+
detail: `${track.instrument}${track.keys.preset ? ` · ${track.keys.preset}` : ""}`,
|
|
543
|
+
help: "the modelled piano: preset, touch, hammers, dampers, stretch",
|
|
544
|
+
build: (inner) => {
|
|
545
|
+
const current = focused(inner);
|
|
546
|
+
return current
|
|
547
|
+
? keysNodes(current, Object.keys(KEYS_PARAMS))
|
|
548
|
+
: [];
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
]
|
|
552
|
+
: [];
|
|
489
553
|
return [
|
|
490
554
|
...parameterNodes(context),
|
|
555
|
+
...granular,
|
|
556
|
+
...keys,
|
|
491
557
|
...tuning,
|
|
492
558
|
{
|
|
493
559
|
kind: "menu",
|
|
@@ -903,7 +969,7 @@ function trackNodes(context: MenuContext): MenuNode[] {
|
|
|
903
969
|
}
|
|
904
970
|
|
|
905
971
|
function instrumentNode(track: Track): MenuNode {
|
|
906
|
-
const options: string[] = [...AVAILABLE_INSTRUMENTS];
|
|
972
|
+
const options: string[] = [...AVAILABLE_INSTRUMENTS, ...PIANO_FAMILIES];
|
|
907
973
|
if (!options.includes(track.instrument)) options.push(track.instrument);
|
|
908
974
|
return {
|
|
909
975
|
kind: "choice",
|
|
@@ -956,6 +1022,30 @@ function parameterNodes(context: MenuContext): MenuNode[] {
|
|
|
956
1022
|
// A wavetable track also has the synth voice's envelope, filters and FM.
|
|
957
1023
|
if (isWavetableInstrument(track.instrument))
|
|
958
1024
|
nodes.push(...wavetableNodes(track, context.projectRoot));
|
|
1025
|
+
if (isStringTrack(track)) {
|
|
1026
|
+
nodes.push(...stringNodes(track, STRING_SIMPLE_PARAMS));
|
|
1027
|
+
nodes.push({
|
|
1028
|
+
kind: "menu",
|
|
1029
|
+
id: "string:advanced",
|
|
1030
|
+
label: "advanced",
|
|
1031
|
+
detail: `all ${Object.keys(STRING_PARAMS).length} params`,
|
|
1032
|
+
help: "every string parameter: exciter, loss, body, buzz, sympathetics",
|
|
1033
|
+
build: (inner) => {
|
|
1034
|
+
const current = focused(inner);
|
|
1035
|
+
return current
|
|
1036
|
+
? stringNodes(current, Object.keys(STRING_PARAMS), false)
|
|
1037
|
+
: [];
|
|
1038
|
+
},
|
|
1039
|
+
});
|
|
1040
|
+
if (Object.keys(track.string ?? {}).some((key) => key !== "preset"))
|
|
1041
|
+
nodes.push({
|
|
1042
|
+
kind: "action",
|
|
1043
|
+
label: "reset to preset",
|
|
1044
|
+
command: "string reset",
|
|
1045
|
+
help: "drop this track's string overrides, keep the preset",
|
|
1046
|
+
});
|
|
1047
|
+
return nodes;
|
|
1048
|
+
}
|
|
959
1049
|
if (track.sampler) {
|
|
960
1050
|
nodes.push({
|
|
961
1051
|
kind: "info",
|
|
@@ -982,6 +1072,22 @@ function parameterNodes(context: MenuContext): MenuNode[] {
|
|
|
982
1072
|
label: "add a voice",
|
|
983
1073
|
value: "/sample <path> [as <voice>]",
|
|
984
1074
|
});
|
|
1075
|
+
} else if (isPianoFamily(track.instrument) && track.keys) {
|
|
1076
|
+
nodes.push(...keysNodes(track, KEYS_SIMPLE));
|
|
1077
|
+
nodes.push({
|
|
1078
|
+
kind: "menu",
|
|
1079
|
+
id: "keys:all",
|
|
1080
|
+
label: "all piano params",
|
|
1081
|
+
detail: `all ${Object.keys(KEYS_PARAMS).length} params`,
|
|
1082
|
+
help: "every modelled piano parameter",
|
|
1083
|
+
build: (inner) => {
|
|
1084
|
+
const current = focused(inner);
|
|
1085
|
+
return current ? keysNodes(current, Object.keys(KEYS_PARAMS)) : [];
|
|
1086
|
+
},
|
|
1087
|
+
});
|
|
1088
|
+
} else if (track.instrument === "modal") {
|
|
1089
|
+
// 0.6 modal percussion: preset, mallet and MODAL_PARAMS rows.
|
|
1090
|
+
nodes.push(...modalParameterNodes(track));
|
|
985
1091
|
} else if (!isDrumInstrument(track.instrument)) {
|
|
986
1092
|
nodes.push(...synthNodes(track, SYNTH_SIMPLE));
|
|
987
1093
|
nodes.push({
|
|
@@ -1089,6 +1195,44 @@ function sampleVoiceNodes(context: MenuContext, voice: string): MenuNode[] {
|
|
|
1089
1195
|
label: "cut / loopAt",
|
|
1090
1196
|
value: `/sample set ${voice} cut hats · loopAt 2`,
|
|
1091
1197
|
},
|
|
1198
|
+
// 0.6 fit: the sample's own tempo, its length in beats, and how it fits.
|
|
1199
|
+
{
|
|
1200
|
+
kind: "number",
|
|
1201
|
+
label: "bpm",
|
|
1202
|
+
value: ref.bpm,
|
|
1203
|
+
start: Math.round(context.score.tempoBpm),
|
|
1204
|
+
off: "off",
|
|
1205
|
+
min: SCORE_LIMITS.minSampleBpm,
|
|
1206
|
+
max: SCORE_LIMITS.maxSampleBpm,
|
|
1207
|
+
step: linear(1, SCORE_LIMITS.minSampleBpm, SCORE_LIMITS.maxSampleBpm),
|
|
1208
|
+
format: num,
|
|
1209
|
+
command: (value) => `/bpm ${num(value)} ${voice}`,
|
|
1210
|
+
reset: `/bpm off ${voice}`,
|
|
1211
|
+
help: SAMPLE_CONTROLS.bpm,
|
|
1212
|
+
},
|
|
1213
|
+
{
|
|
1214
|
+
kind: "choice",
|
|
1215
|
+
label: "fitmode",
|
|
1216
|
+
// off: unset (plays as repitch); auto suggests one from the sound.
|
|
1217
|
+
value: ref.fitmode ?? "off",
|
|
1218
|
+
options: ["off", ...SAMPLE_FIT_MODES, "auto"],
|
|
1219
|
+
command: (option) => `/fitmode ${option} ${voice}`,
|
|
1220
|
+
help: `${SAMPLE_CONTROLS.fitmode} · beats for drums, tones for pads · set bpm or len first`,
|
|
1221
|
+
},
|
|
1222
|
+
{
|
|
1223
|
+
kind: "number",
|
|
1224
|
+
label: "len",
|
|
1225
|
+
value: ref.len,
|
|
1226
|
+
start: 4,
|
|
1227
|
+
off: "off",
|
|
1228
|
+
min: 0.25,
|
|
1229
|
+
max: SCORE_LIMITS.maxSampleLenBeats,
|
|
1230
|
+
step: linear(0.25, 0.25, SCORE_LIMITS.maxSampleLenBeats),
|
|
1231
|
+
format: num,
|
|
1232
|
+
command: (value) => `/len ${num(value)} ${voice}`,
|
|
1233
|
+
reset: `/len off ${voice}`,
|
|
1234
|
+
help: SAMPLE_CONTROLS.len,
|
|
1235
|
+
},
|
|
1092
1236
|
);
|
|
1093
1237
|
return nodes;
|
|
1094
1238
|
}
|
|
@@ -1105,6 +1249,57 @@ function matchingSynthPreset(track: Track): string | undefined {
|
|
|
1105
1249
|
)?.[0];
|
|
1106
1250
|
}
|
|
1107
1251
|
|
|
1252
|
+
/** Modelled piano rows: the preset, then each parameter (`keys <p> <v>`). */
|
|
1253
|
+
function keysNodes(track: Track, params: readonly string[]): MenuNode[] {
|
|
1254
|
+
const effective = resolvedKeys(track.instrument, track.keys);
|
|
1255
|
+
const nodes: MenuNode[] = [
|
|
1256
|
+
{
|
|
1257
|
+
kind: "choice",
|
|
1258
|
+
label: "preset",
|
|
1259
|
+
value: track.keys?.preset ?? "—",
|
|
1260
|
+
options: Object.keys(KEYS_PRESETS),
|
|
1261
|
+
command: (preset) => `keys preset ${preset}`,
|
|
1262
|
+
help: "a starting piano; every value stays editable",
|
|
1263
|
+
},
|
|
1264
|
+
];
|
|
1265
|
+
for (const key of params) {
|
|
1266
|
+
const param = KEYS_PARAMS[key]!;
|
|
1267
|
+
const stored = track.keys?.[key];
|
|
1268
|
+
if (param.kind === "number") {
|
|
1269
|
+
const base = effective[key] as number;
|
|
1270
|
+
nodes.push({
|
|
1271
|
+
kind: "number",
|
|
1272
|
+
label: key,
|
|
1273
|
+
value: typeof stored === "number" ? stored : undefined,
|
|
1274
|
+
start: base,
|
|
1275
|
+
off: withUnit(formatParam(param, base), param.unit),
|
|
1276
|
+
min: param.min,
|
|
1277
|
+
max: param.max,
|
|
1278
|
+
step: specStep(param),
|
|
1279
|
+
format: (value) => withUnit(formatParam(param, value), param.unit),
|
|
1280
|
+
command: (value) => `keys ${key} ${formatParam(param, value)}`,
|
|
1281
|
+
reset: `keys ${key} off`,
|
|
1282
|
+
help: param.doc,
|
|
1283
|
+
});
|
|
1284
|
+
} else if (param.kind === "enum")
|
|
1285
|
+
nodes.push({
|
|
1286
|
+
kind: "choice",
|
|
1287
|
+
label: key,
|
|
1288
|
+
value: typeof stored === "string" ? stored : String(effective[key]),
|
|
1289
|
+
options: param.values,
|
|
1290
|
+
command: (option) => `keys ${key} ${option}`,
|
|
1291
|
+
help: param.doc,
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
nodes.push({
|
|
1295
|
+
kind: "action",
|
|
1296
|
+
label: "reset to the family",
|
|
1297
|
+
command: "keys reset",
|
|
1298
|
+
help: "clear every override and the preset, and the effects the preset added (keeps the family's own sound)",
|
|
1299
|
+
});
|
|
1300
|
+
return nodes;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1108
1303
|
function synthNodes(track: Track, keys: readonly string[]): MenuNode[] {
|
|
1109
1304
|
const nodes: MenuNode[] = [];
|
|
1110
1305
|
if (keys === SYNTH_SIMPLE)
|
|
@@ -1175,6 +1370,57 @@ const SYNTH_LABEL: Readonly<Record<string, string>> = {
|
|
|
1175
1370
|
fm: "FM amount",
|
|
1176
1371
|
};
|
|
1177
1372
|
|
|
1373
|
+
/** String preset first (simple view), then one row per parameter. */
|
|
1374
|
+
function stringNodes(
|
|
1375
|
+
track: Track,
|
|
1376
|
+
keys: readonly string[],
|
|
1377
|
+
withPreset = true,
|
|
1378
|
+
): MenuNode[] {
|
|
1379
|
+
const nodes: MenuNode[] = [];
|
|
1380
|
+
const preset = STRING_PRESETS[stringPresetOf(track.string)]!;
|
|
1381
|
+
if (withPreset)
|
|
1382
|
+
nodes.push({
|
|
1383
|
+
kind: "choice",
|
|
1384
|
+
label: "preset",
|
|
1385
|
+
help: preset.doc,
|
|
1386
|
+
value: stringPresetOf(track.string),
|
|
1387
|
+
options: STRING_PRESET_NAMES,
|
|
1388
|
+
command: (name) => `string preset ${name}`,
|
|
1389
|
+
});
|
|
1390
|
+
for (const key of keys) {
|
|
1391
|
+
const param = STRING_PARAMS[key]!;
|
|
1392
|
+
const own = track.string?.[key];
|
|
1393
|
+
const base = preset.values[key] ?? param.default;
|
|
1394
|
+
if (param.kind === "number")
|
|
1395
|
+
nodes.push({
|
|
1396
|
+
kind: "number",
|
|
1397
|
+
label: param.unit ? `${key} ${param.unit}` : key,
|
|
1398
|
+
help: param.doc,
|
|
1399
|
+
value: typeof own === "number" ? own : undefined,
|
|
1400
|
+
start: typeof base === "number" ? base : param.default,
|
|
1401
|
+
off: formatParam(
|
|
1402
|
+
param,
|
|
1403
|
+
typeof base === "number" ? base : param.default,
|
|
1404
|
+
),
|
|
1405
|
+
min: param.min,
|
|
1406
|
+
max: param.max,
|
|
1407
|
+
step: specStep(param),
|
|
1408
|
+
format: (value) => formatParam(param, value),
|
|
1409
|
+
command: (value) => `string ${key} ${formatParam(param, value)}`,
|
|
1410
|
+
});
|
|
1411
|
+
else if (param.kind === "enum")
|
|
1412
|
+
nodes.push({
|
|
1413
|
+
kind: "choice",
|
|
1414
|
+
label: key,
|
|
1415
|
+
help: param.doc,
|
|
1416
|
+
value: typeof own === "string" ? own : String(base),
|
|
1417
|
+
options: param.values,
|
|
1418
|
+
command: (option) => `string ${key} ${option}`,
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
return nodes;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1178
1424
|
/** Every synth parameter, grouped (amplitude, oscillator, FM 1..8, …). */
|
|
1179
1425
|
function synthAdvancedNodes(context: MenuContext): MenuNode[] {
|
|
1180
1426
|
const track = focused(context);
|
|
@@ -1324,10 +1570,38 @@ function effectNodes(context: MenuContext): MenuNode[] {
|
|
|
1324
1570
|
build: (inner) => effectParamNodes(inner, effect, false),
|
|
1325
1571
|
};
|
|
1326
1572
|
};
|
|
1327
|
-
const
|
|
1573
|
+
const rigStages = RIG_STAGES as readonly string[];
|
|
1574
|
+
const more = EFFECT_NAMES.filter(
|
|
1575
|
+
(effect) => !CORE_EFFECTS.includes(effect) && !rigStages.includes(effect),
|
|
1576
|
+
);
|
|
1328
1577
|
const moreOn = more.filter((effect) => effectValues(track, effect));
|
|
1578
|
+
// 0.6 guitar rig: stomp → head (with its gate) → cab, plus whole rigs.
|
|
1579
|
+
const rigOn = RIG_STAGES.filter((stage) => effectValues(track, stage));
|
|
1580
|
+
const rig: MenuNode = {
|
|
1581
|
+
kind: "menu",
|
|
1582
|
+
id: "guitar rig",
|
|
1583
|
+
label: "Guitar rig",
|
|
1584
|
+
help: "stomp box, amp head with noise gate and speaker cabinet; `rig <name>` loads a whole rig",
|
|
1585
|
+
detail:
|
|
1586
|
+
rigOn.length === 0 ? "off" : (rigPresetOf(track.fx) ?? rigOn.join(" → ")),
|
|
1587
|
+
build: (inner) => {
|
|
1588
|
+
const current = focused(inner);
|
|
1589
|
+
return [
|
|
1590
|
+
{
|
|
1591
|
+
kind: "choice",
|
|
1592
|
+
label: "rig",
|
|
1593
|
+
value: rigPresetOf(current?.fx) ?? "—",
|
|
1594
|
+
options: [...Object.keys(RIG_PRESETS), "reset"],
|
|
1595
|
+
command: (name) => `rig ${name}`,
|
|
1596
|
+
help: "a whole rig (stomp, head, cab); every stage stays editable",
|
|
1597
|
+
},
|
|
1598
|
+
...RIG_STAGES.map(node),
|
|
1599
|
+
];
|
|
1600
|
+
},
|
|
1601
|
+
};
|
|
1329
1602
|
return [
|
|
1330
1603
|
...CORE_EFFECTS.map(node),
|
|
1604
|
+
rig,
|
|
1331
1605
|
{
|
|
1332
1606
|
kind: "menu",
|
|
1333
1607
|
id: "more effects",
|
|
@@ -1495,6 +1769,9 @@ function automationNodes(context: MenuContext): MenuNode[] {
|
|
|
1495
1769
|
const info = FX_LANE_INFO.get(lane as FxLane);
|
|
1496
1770
|
if (info?.effect === "synth")
|
|
1497
1771
|
return track.synth?.[info.param] !== undefined;
|
|
1772
|
+
if (info?.effect === "string") return track.string !== undefined;
|
|
1773
|
+
if (info?.effect === "keys") return track.keys !== undefined;
|
|
1774
|
+
if (info?.effect === "modal") return track.modal !== undefined;
|
|
1498
1775
|
return info !== undefined && effectValues(track, info.effect) !== undefined;
|
|
1499
1776
|
});
|
|
1500
1777
|
const hidden = AUTOMATION_PARAMETERS.filter((lane) => !shown.includes(lane));
|
|
@@ -1676,14 +1953,54 @@ function patternsMenu(): MenuNode {
|
|
|
1676
1953
|
};
|
|
1677
1954
|
}
|
|
1678
1955
|
|
|
1956
|
+
const GRANULAR_PRESETS_COUNT = granularBrowseNodes().length;
|
|
1957
|
+
|
|
1679
1958
|
function soundNodes(): MenuNode[] {
|
|
1680
1959
|
return [
|
|
1960
|
+
{
|
|
1961
|
+
kind: "menu",
|
|
1962
|
+
id: "group:keys",
|
|
1963
|
+
label: "Keys",
|
|
1964
|
+
help: "modelled pianos, built in (no download)",
|
|
1965
|
+
detail: Object.keys(KEYS_PRESETS).join(" "),
|
|
1966
|
+
build: () =>
|
|
1967
|
+
Object.entries(KEYS_PRESETS).map(([name, preset]): MenuNode => ({
|
|
1968
|
+
kind: "action",
|
|
1969
|
+
label: `${name.padEnd(10)} ${preset.doc}`,
|
|
1970
|
+
command: `piano ${name}`,
|
|
1971
|
+
help: preset.styles,
|
|
1972
|
+
})),
|
|
1973
|
+
},
|
|
1681
1974
|
{
|
|
1682
1975
|
kind: "action",
|
|
1683
1976
|
label: "wavetable synth basic shapes morph · built-in",
|
|
1684
1977
|
command: "wt basic",
|
|
1685
1978
|
help: "switch this track to the morphing wavetable synth",
|
|
1686
1979
|
},
|
|
1980
|
+
// 0.6 instrument groups, one per lane.
|
|
1981
|
+
malletsMenu(),
|
|
1982
|
+
{
|
|
1983
|
+
kind: "menu",
|
|
1984
|
+
id: "strings",
|
|
1985
|
+
label: "Strings",
|
|
1986
|
+
help: "plucked strings: guitars, basses, sitar, harpsichord, oud, koto…",
|
|
1987
|
+
detail: `${STRING_PRESET_NAMES.length} plucked · built-in`,
|
|
1988
|
+
build: () =>
|
|
1989
|
+
STRING_PRESET_NAMES.map((name): MenuNode => ({
|
|
1990
|
+
kind: "action",
|
|
1991
|
+
label: `${name.padEnd(12)} ${STRING_PRESETS[name]!.doc}`,
|
|
1992
|
+
command: `string ${name}`,
|
|
1993
|
+
help: STRING_PRESETS[name]!.styles,
|
|
1994
|
+
})),
|
|
1995
|
+
},
|
|
1996
|
+
{
|
|
1997
|
+
kind: "menu",
|
|
1998
|
+
id: "granular-browse",
|
|
1999
|
+
label: "Granular",
|
|
2000
|
+
help: "grain clouds from a built-in synth or this track's own sound",
|
|
2001
|
+
detail: `${GRANULAR_PRESETS_COUNT} presets · built-in`,
|
|
2002
|
+
build: granularBrowseNodes,
|
|
2003
|
+
},
|
|
1687
2004
|
{
|
|
1688
2005
|
kind: "menu",
|
|
1689
2006
|
id: "instruments",
|
|
@@ -2372,11 +2689,17 @@ export class EditMenu {
|
|
|
2372
2689
|
candidate.kind === "choice" &&
|
|
2373
2690
|
candidate.label === node.label,
|
|
2374
2691
|
) ?? node;
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2692
|
+
// The current value is shown, not re-run: re-confirming a
|
|
2693
|
+
// legacy `piano` would otherwise re-voice it as the grand.
|
|
2694
|
+
return live.options.map((option): MenuNode =>
|
|
2695
|
+
option === live.value
|
|
2696
|
+
? { kind: "info", label: `${option} ✓`, value: "current" }
|
|
2697
|
+
: {
|
|
2698
|
+
kind: "action",
|
|
2699
|
+
label: option,
|
|
2700
|
+
command: live.command(option),
|
|
2701
|
+
},
|
|
2702
|
+
);
|
|
2380
2703
|
},
|
|
2381
2704
|
});
|
|
2382
2705
|
return { type: "handled" };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ctrl-k menu rows for modal percussion (0.6): the "Mallets and bells"
|
|
3
|
+
* group under Sound › browse sounds, and the MODAL_PARAMS rows under
|
|
4
|
+
* Sound › Parameters on a modal track. Every row runs a `modal …` command.
|
|
5
|
+
*/
|
|
6
|
+
import type { EnumParam, NumberParam } from "../../core/params.ts";
|
|
7
|
+
import {
|
|
8
|
+
MODAL_INSTRUMENT,
|
|
9
|
+
MODAL_MALLET_NAMES,
|
|
10
|
+
MODAL_PARAMS,
|
|
11
|
+
MODAL_PRESETS,
|
|
12
|
+
MODAL_PRESET_NAMES,
|
|
13
|
+
MODAL_SIMPLE,
|
|
14
|
+
modalSettings,
|
|
15
|
+
} from "../../core/resonators.ts";
|
|
16
|
+
import type { Track } from "../../core/score.ts";
|
|
17
|
+
import { num, specStep, type MenuNode } from "./menu.ts";
|
|
18
|
+
|
|
19
|
+
/** Sound › browse sounds › Mallets and bells: one row per preset. */
|
|
20
|
+
export function malletsMenu(): MenuNode {
|
|
21
|
+
return {
|
|
22
|
+
kind: "menu",
|
|
23
|
+
id: "mallets",
|
|
24
|
+
label: "Mallets and bells",
|
|
25
|
+
detail: `${MODAL_PRESET_NAMES.length} modal presets · marimba, vibes, gong …`,
|
|
26
|
+
help: "struck bars, bells and bowls on the modal resonator engine",
|
|
27
|
+
build: () =>
|
|
28
|
+
MODAL_PRESET_NAMES.map((name): MenuNode => ({
|
|
29
|
+
kind: "action",
|
|
30
|
+
label: `${name} ${MODAL_PRESETS[name].doc}`,
|
|
31
|
+
command: `modal ${name}`,
|
|
32
|
+
help: MODAL_PRESETS[name].styles,
|
|
33
|
+
})),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function numberRow(
|
|
38
|
+
name: string,
|
|
39
|
+
spec: NumberParam,
|
|
40
|
+
value: number,
|
|
41
|
+
fallback: number,
|
|
42
|
+
): MenuNode {
|
|
43
|
+
return {
|
|
44
|
+
kind: "number",
|
|
45
|
+
label: name,
|
|
46
|
+
value,
|
|
47
|
+
min: spec.min,
|
|
48
|
+
max: spec.max,
|
|
49
|
+
step: specStep(spec),
|
|
50
|
+
format: (v) => (spec.unit ? `${num(v)} ${spec.unit}` : num(v)),
|
|
51
|
+
command: (v) => `modal ${name} ${num(v)}`,
|
|
52
|
+
reset: `modal ${name} off`,
|
|
53
|
+
help: `${spec.doc} · preset ${num(fallback)} · x resets`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Sound › Parameters rows on a modal track. */
|
|
58
|
+
export function modalParameterNodes(track: Track): MenuNode[] {
|
|
59
|
+
if (track.instrument !== MODAL_INSTRUMENT) return [];
|
|
60
|
+
const modal = track.modal;
|
|
61
|
+
const preset = MODAL_PRESETS[modal?.preset ?? "marimba"].settings;
|
|
62
|
+
const resolved = modalSettings(modal) as Readonly<
|
|
63
|
+
Record<string, number | string>
|
|
64
|
+
>;
|
|
65
|
+
const row = (name: string): MenuNode | undefined => {
|
|
66
|
+
const spec = MODAL_PARAMS[name]!;
|
|
67
|
+
if (name === "mallet")
|
|
68
|
+
return {
|
|
69
|
+
kind: "choice",
|
|
70
|
+
label: "mallet",
|
|
71
|
+
value: modal?.mallet ?? "preset",
|
|
72
|
+
options: ["preset", ...MODAL_MALLET_NAMES],
|
|
73
|
+
command: (option) =>
|
|
74
|
+
option === "preset" ? "modal mallet off" : `modal mallet ${option}`,
|
|
75
|
+
help: (spec as EnumParam).doc,
|
|
76
|
+
};
|
|
77
|
+
if (spec.kind !== "number") return undefined;
|
|
78
|
+
return numberRow(
|
|
79
|
+
name,
|
|
80
|
+
spec,
|
|
81
|
+
Number(resolved[name]),
|
|
82
|
+
Number(preset[name as keyof typeof preset]),
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
const nodes: MenuNode[] = [
|
|
86
|
+
{
|
|
87
|
+
kind: "choice",
|
|
88
|
+
label: "preset",
|
|
89
|
+
value: modal?.preset ?? "marimba",
|
|
90
|
+
options: MODAL_PRESET_NAMES,
|
|
91
|
+
command: (option) => `modal preset ${option}`,
|
|
92
|
+
help: "the instrument: mode table, mallet and ring",
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
for (const name of MODAL_SIMPLE) {
|
|
96
|
+
const node = row(name);
|
|
97
|
+
if (node) nodes.push(node);
|
|
98
|
+
}
|
|
99
|
+
const rest = Object.keys(MODAL_PARAMS).filter(
|
|
100
|
+
(name) => !(MODAL_SIMPLE as readonly string[]).includes(name),
|
|
101
|
+
);
|
|
102
|
+
nodes.push({
|
|
103
|
+
kind: "menu",
|
|
104
|
+
id: "modal:advanced",
|
|
105
|
+
label: "advanced",
|
|
106
|
+
detail: `motor, ombak, buzz, click, strike glide, gain`,
|
|
107
|
+
help: "every modal parameter",
|
|
108
|
+
build: () => rest.flatMap((name) => row(name) ?? []),
|
|
109
|
+
});
|
|
110
|
+
if (modal && Object.keys(modal).some((key) => key !== "preset"))
|
|
111
|
+
nodes.push({
|
|
112
|
+
kind: "action",
|
|
113
|
+
label: "reset to preset",
|
|
114
|
+
command: "modal reset",
|
|
115
|
+
help: "clear overrides, keep the preset",
|
|
116
|
+
});
|
|
117
|
+
return nodes;
|
|
118
|
+
}
|
package/src/tui/play-session.ts
CHANGED
|
@@ -48,11 +48,13 @@ import {
|
|
|
48
48
|
transportBarStart,
|
|
49
49
|
} from "../../core/tempo.ts";
|
|
50
50
|
import {
|
|
51
|
+
LiveFullRenderer,
|
|
51
52
|
LiveSynth,
|
|
52
53
|
MAX_LIVE_NOTE_SECONDS,
|
|
53
54
|
type LiveNotePcm,
|
|
54
55
|
} from "../audio/live.ts";
|
|
55
56
|
import type { SampleBank } from "../audio/samples.ts";
|
|
57
|
+
import { fitting, onFitReady } from "../audio/fit.ts";
|
|
56
58
|
import {
|
|
57
59
|
DEFAULT_STRUM,
|
|
58
60
|
perform,
|
|
@@ -91,6 +93,8 @@ export interface LiveEngine {
|
|
|
91
93
|
setLeadMs(ms: number | undefined): void;
|
|
92
94
|
noteOn(id: number, note: LiveNotePcm): number;
|
|
93
95
|
noteOff(id: number): void;
|
|
96
|
+
/** Frames of voice `id` mixed so far, or undefined once it has ended. */
|
|
97
|
+
voicePosition?(id: number): number | undefined;
|
|
94
98
|
setClick(click: ClickBus | undefined): void;
|
|
95
99
|
}
|
|
96
100
|
|
|
@@ -134,6 +138,8 @@ export const GRIDS: readonly Readonly<{ label: string; beats: number }>[] =
|
|
|
134
138
|
export const DEFAULT_GRID = "1/16";
|
|
135
139
|
/** Play mode's queue lead: short enough that a key feels immediate. */
|
|
136
140
|
export const PLAY_LEAD_MS = 60;
|
|
141
|
+
/** Status while a long fitted sample window computes (fit.ts). */
|
|
142
|
+
const FITTING_STATUS = "fitting · the sample plays once ready";
|
|
137
143
|
export const FLASH_MS = 110;
|
|
138
144
|
|
|
139
145
|
export function gridBeats(label: string): number | undefined {
|
|
@@ -221,11 +227,16 @@ export class PlaySession {
|
|
|
221
227
|
public countInBars = 1;
|
|
222
228
|
public grid = DEFAULT_GRID;
|
|
223
229
|
public status: string | undefined;
|
|
230
|
+
/** Unsubscribes the pending "fit ready" listener (fit.ts). */
|
|
231
|
+
private stopFitWait: (() => void) | undefined;
|
|
224
232
|
/** Scale-degree layout on (`i`); follows key and tuning changes. */
|
|
225
233
|
public degreesOn = false;
|
|
226
234
|
private degreeKey: string | undefined;
|
|
227
235
|
private active = false;
|
|
228
236
|
private synth: LiveSynth | undefined;
|
|
237
|
+
/** Rig voices awaiting their full render: id → released since. */
|
|
238
|
+
private readonly windows = new Map<number, boolean>();
|
|
239
|
+
private readonly fullRenderer = new LiveFullRenderer();
|
|
229
240
|
private readonly pending = new Map<number, Pending>();
|
|
230
241
|
/** Sustain changes while recording (Tab latch or Shift), as pedal events. */
|
|
231
242
|
private pendingPedal: { beat: number; state: PedalState }[] = [];
|
|
@@ -597,7 +608,7 @@ export class PlaySession {
|
|
|
597
608
|
if (this.synth?.rate !== engine.sampleRate)
|
|
598
609
|
this.synth = new LiveSynth(engine.sampleRate);
|
|
599
610
|
const score = this.host.score();
|
|
600
|
-
const
|
|
611
|
+
const request = {
|
|
601
612
|
score,
|
|
602
613
|
trackId: this.trackId,
|
|
603
614
|
pitch: note.pitch,
|
|
@@ -607,16 +618,48 @@ export class PlaySession {
|
|
|
607
618
|
...(score.time?.tempo
|
|
608
619
|
? { tick: loopTickAt(score, this.host.beatAt(note.atMs)) }
|
|
609
620
|
: {}),
|
|
610
|
-
}
|
|
621
|
+
};
|
|
622
|
+
const synth = this.synth;
|
|
623
|
+
const pcm = synth.render(request);
|
|
611
624
|
if (!pcm) return;
|
|
625
|
+
if (pcm.fitting) {
|
|
626
|
+
// A long fitted sample window is computing: silent, never off-pitch.
|
|
627
|
+
this.status = FITTING_STATUS;
|
|
628
|
+
this.stopFitWait ??= onFitReady(() => {
|
|
629
|
+
if (fitting()) return;
|
|
630
|
+
this.stopFitWait?.();
|
|
631
|
+
this.stopFitWait = undefined;
|
|
632
|
+
if (this.status === FITTING_STATUS)
|
|
633
|
+
this.status = "fit ready · play the key again";
|
|
634
|
+
});
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
612
637
|
const scheduled = engine.noteOn(id, pcm);
|
|
613
638
|
this.lastLatencyMs = Math.max(0, scheduled - this.host.now());
|
|
639
|
+
// A guitar rig sounds its first window now; the rest renders after the
|
|
640
|
+
// key event and replaces the voice in place while it is still sounding.
|
|
641
|
+
this.windows.delete(id);
|
|
642
|
+
if (pcm.partial) this.windows.set(id, false);
|
|
643
|
+
// The full pass runs on a worker, in key order, so a strummed chord or a
|
|
644
|
+
// fast run never stalls key handling behind an oversampled render.
|
|
645
|
+
if (pcm.partial)
|
|
646
|
+
void this.fullRenderer.full(synth, request).then((full) => {
|
|
647
|
+
// Not after note-off: a swap would cancel the release fade.
|
|
648
|
+
if (
|
|
649
|
+
full &&
|
|
650
|
+
this.windows.get(id) === false &&
|
|
651
|
+
engine.voicePosition?.(id) !== undefined
|
|
652
|
+
)
|
|
653
|
+
engine.noteOn(id, full);
|
|
654
|
+
this.windows.delete(id);
|
|
655
|
+
});
|
|
614
656
|
}
|
|
615
657
|
|
|
616
658
|
private release(ids: readonly number[], atMs: number): void {
|
|
617
659
|
const engine = this.host.engine();
|
|
618
660
|
for (const id of ids) {
|
|
619
661
|
engine?.noteOff(id);
|
|
662
|
+
if (this.windows.has(id)) this.windows.set(id, true);
|
|
620
663
|
const live = this.liveChords.get(id);
|
|
621
664
|
if (live) live.releaseAtMs = Math.min(live.releaseAtMs, atMs);
|
|
622
665
|
const pending = this.pending.get(id);
|