@hraness/dawg 0.4.1 → 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 +99 -0
- package/DAWG.md +551 -33
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +41 -12
- package/core/expression.ts +1241 -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/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/resonators.ts +574 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +717 -62
- package/core/sdk/eval-child.ts +40 -3
- package/core/sdk/print.ts +614 -27
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +2824 -45
- package/core/sections.ts +2072 -0
- package/core/strings.ts +845 -0
- package/core/synth.ts +11 -1
- package/core/tempo.ts +1318 -0
- package/core/tuning.ts +1180 -0
- package/guides/audition.md +26 -0
- package/guides/automation.md +26 -0
- package/guides/chords.md +28 -0
- package/guides/effects.md +29 -0
- package/guides/faders.md +29 -0
- package/guides/files.md +28 -0
- package/guides/getting-started.md +26 -0
- package/guides/index.ts +75 -0
- package/guides/keys.md +27 -0
- package/guides/media.md +27 -0
- package/guides/mix.md +23 -0
- package/guides/music.md +14 -0
- package/guides/notes.md +26 -0
- package/guides/performance.md +26 -0
- package/guides/play.md +24 -0
- package/guides/project.md +13 -0
- package/guides/providers.md +25 -0
- package/guides/rhythm.md +26 -0
- package/guides/sessions.md +21 -0
- package/guides/sound.md +15 -0
- package/guides/sounds.md +29 -0
- package/guides/tempo.md +27 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +9 -0
- package/src/agent/brief.ts +77 -3
- package/src/agent/chord-tools.ts +9 -1
- package/src/agent/expression-tools.ts +336 -0
- package/src/agent/granular-tools.ts +138 -0
- package/src/agent/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +68 -6
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +27 -4
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +574 -16
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +9 -0
- package/src/audio/arrange.ts +489 -0
- package/src/audio/audition.ts +16 -3
- package/src/audio/click.ts +113 -1
- package/src/audio/clock.ts +71 -5
- 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/bus.ts +5 -4
- package/src/audio/effects/chain.ts +6 -1
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/dynamics.ts +2 -1
- package/src/audio/effects/filter.ts +5 -3
- package/src/audio/effects/modulation.ts +5 -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/effects/space.ts +92 -2
- package/src/audio/engine.ts +100 -21
- 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 +263 -17
- package/src/audio/loudness.ts +596 -0
- package/src/audio/master.ts +660 -0
- package/src/audio/measure-worker.ts +45 -0
- package/src/audio/measure.ts +110 -0
- package/src/audio/player.ts +11 -6
- package/src/audio/preview.ts +76 -14
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/resonators.ts +287 -0
- package/src/audio/sampler.ts +269 -34
- 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/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +147 -0
- package/src/audio/wav.ts +377 -30
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +941 -0
- 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 +355 -27
- package/src/commands/keys.ts +353 -0
- package/src/commands/master.ts +361 -0
- package/src/commands/modal.ts +247 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/rig.ts +260 -0
- package/src/commands/sample.ts +3 -0
- package/src/commands/string.ts +175 -0
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +967 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +919 -41
- package/src/project/check.ts +13 -0
- package/src/render.ts +139 -8
- package/src/session/daemon.ts +18 -8
- package/src/session/naming.ts +8 -1
- package/src/session/rebase.ts +21 -0
- package/src/tui/arrange-menu.ts +390 -0
- package/src/tui/audition.ts +38 -5
- package/src/tui/fader.ts +409 -0
- package/src/tui/granular-menu.ts +278 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +967 -23
- package/src/tui/modal-menu.ts +118 -0
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +3 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +459 -43
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +63 -1
- package/tui/guide.ts +351 -0
- package/tui/highway.ts +87 -4
- package/tui/hits.ts +68 -0
- package/tui/input.ts +7 -0
- package/tui/keys.ts +72 -0
package/src/tui/fader.ts
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fader mode: the keys and mouse gestures of the fader drawer
|
|
3
|
+
* (tui/drawer.ts), as pure functions over a list of fields.
|
|
4
|
+
*
|
|
5
|
+
* A field is a number (with a range, a step function and a formatter taken
|
|
6
|
+
* from the menu row, which in turn takes them from core/params.ts, the fx
|
|
7
|
+
* specs and the automation lanes) or a choice (a segmented selector). Every
|
|
8
|
+
* change is a command: the caller stages it on the audition loop, keyed by
|
|
9
|
+
* field, so repeated nudges replace one staged edit and Enter keeps them
|
|
10
|
+
* all as one revision.
|
|
11
|
+
*/
|
|
12
|
+
import type { DrawerField, DrawerView } from "../../tui/drawer.ts";
|
|
13
|
+
|
|
14
|
+
export type FaderNumber = Readonly<{
|
|
15
|
+
kind: "number";
|
|
16
|
+
label: string;
|
|
17
|
+
value: number | undefined;
|
|
18
|
+
min: number;
|
|
19
|
+
max: number;
|
|
20
|
+
step: (value: number, direction: 1 | -1) => number;
|
|
21
|
+
format: (value: number) => string;
|
|
22
|
+
command: (value: number) => string;
|
|
23
|
+
/** Typed text → command; undefined when it does not parse or fit. */
|
|
24
|
+
parse: (text: string) => string | undefined;
|
|
25
|
+
/** The command `0` / `d` runs to put the value back to its default. */
|
|
26
|
+
reset?: string | undefined;
|
|
27
|
+
/** Starting value for a nudge while off. */
|
|
28
|
+
start?: number | undefined;
|
|
29
|
+
off?: string | undefined;
|
|
30
|
+
}>;
|
|
31
|
+
|
|
32
|
+
export type FaderChoice = Readonly<{
|
|
33
|
+
kind: "choice";
|
|
34
|
+
label: string;
|
|
35
|
+
value: string;
|
|
36
|
+
options: readonly string[];
|
|
37
|
+
command: (option: string) => string;
|
|
38
|
+
}>;
|
|
39
|
+
|
|
40
|
+
export type FaderSpec = FaderNumber | FaderChoice;
|
|
41
|
+
|
|
42
|
+
export interface FaderState {
|
|
43
|
+
/** The focused field, by label (labels survive rebuilds; indexes may not). */
|
|
44
|
+
label: string;
|
|
45
|
+
/** Digits typed for the focused field. */
|
|
46
|
+
typing?: string | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type FaderResult =
|
|
50
|
+
| { type: "handled" }
|
|
51
|
+
/** Set a field: stage `command`, replacing the field's previous edit. */
|
|
52
|
+
| { type: "set"; command: string; key: string }
|
|
53
|
+
| { type: "keep" }
|
|
54
|
+
| { type: "revert" }
|
|
55
|
+
/** Leave the drawer (nothing staged, or after keep / revert). */
|
|
56
|
+
| { type: "close" }
|
|
57
|
+
| { type: "audition"; key: "loop" | "ab" | "context" }
|
|
58
|
+
| { type: "pass" };
|
|
59
|
+
|
|
60
|
+
/** The key a field's staged edit is filed under. */
|
|
61
|
+
export function faderKey(label: string): string {
|
|
62
|
+
return `fader:${label}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A wide range of positive values reads (and drags) on a log scale. */
|
|
66
|
+
function logScale(spec: FaderNumber): boolean {
|
|
67
|
+
return spec.min > 0 && spec.max / spec.min >= 100;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Where `value` sits along the bar, 0…1. */
|
|
71
|
+
export function faderPosition(spec: FaderNumber, value: number): number {
|
|
72
|
+
if (spec.max <= spec.min) return 0;
|
|
73
|
+
const clamped = Math.min(spec.max, Math.max(spec.min, value));
|
|
74
|
+
if (logScale(spec))
|
|
75
|
+
return Math.log(clamped / spec.min) / Math.log(spec.max / spec.min);
|
|
76
|
+
return (clamped - spec.min) / (spec.max - spec.min);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The value at `position` (0…1), snapped to the field's own step grid: the
|
|
81
|
+
* nearest value its step function can reach from the minimum, so a drag
|
|
82
|
+
* lands on the same values the keys do.
|
|
83
|
+
*/
|
|
84
|
+
export function faderValueAt(spec: FaderNumber, position: number): number {
|
|
85
|
+
const p = Math.min(1, Math.max(0, position));
|
|
86
|
+
if (p <= 0) return spec.min;
|
|
87
|
+
if (p >= 1) return spec.max;
|
|
88
|
+
const raw = logScale(spec)
|
|
89
|
+
? spec.min * (spec.max / spec.min) ** p
|
|
90
|
+
: spec.min + p * (spec.max - spec.min);
|
|
91
|
+
return snap(spec, raw);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The nearest step-reachable value to `raw` (step size near `raw`). */
|
|
95
|
+
function snap(spec: FaderNumber, raw: number): number {
|
|
96
|
+
const up = spec.step(raw, 1);
|
|
97
|
+
const down = spec.step(raw, -1);
|
|
98
|
+
// Linear steps round onto the grid; log steps move by a ratio. Either
|
|
99
|
+
// way the step size near `raw` bounds the precision worth keeping.
|
|
100
|
+
const size = Math.max(
|
|
101
|
+
1e-9,
|
|
102
|
+
Math.min(Math.abs(up - raw), Math.abs(raw - down)) ||
|
|
103
|
+
Math.abs(up - down) / 2,
|
|
104
|
+
);
|
|
105
|
+
const decimals = Math.max(0, Math.min(4, Math.ceil(-Math.log10(size))));
|
|
106
|
+
const candidates = [down, up, Number(raw.toFixed(decimals))];
|
|
107
|
+
// Prefer an exact grid value when one is within half a step.
|
|
108
|
+
let best = candidates[2]!;
|
|
109
|
+
for (const candidate of [down, up])
|
|
110
|
+
if (Math.abs(candidate - raw) <= size / 2 + 1e-9) best = candidate;
|
|
111
|
+
return Math.min(spec.max, Math.max(spec.min, best));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Fine (a tenth of a step), normal, coarse (five steps) or page (twenty). */
|
|
115
|
+
export type StepSize = "fine" | "normal" | "coarse" | "page";
|
|
116
|
+
|
|
117
|
+
export function stepValue(
|
|
118
|
+
spec: FaderNumber,
|
|
119
|
+
direction: 1 | -1,
|
|
120
|
+
size: StepSize = "normal",
|
|
121
|
+
): number {
|
|
122
|
+
if (spec.value === undefined) return spec.start ?? spec.min;
|
|
123
|
+
const clamp = (value: number) =>
|
|
124
|
+
Math.min(spec.max, Math.max(spec.min, value));
|
|
125
|
+
if (size === "fine") {
|
|
126
|
+
const next = spec.step(spec.value, direction);
|
|
127
|
+
const delta = (next - spec.value) / 10;
|
|
128
|
+
if (Math.abs(delta) < 1e-9) return spec.value;
|
|
129
|
+
const decimals = Math.max(
|
|
130
|
+
0,
|
|
131
|
+
Math.min(4, Math.ceil(-Math.log10(Math.abs(delta)))),
|
|
132
|
+
);
|
|
133
|
+
return clamp(Number((spec.value + delta).toFixed(decimals)));
|
|
134
|
+
}
|
|
135
|
+
const times = size === "coarse" ? 5 : size === "page" ? 20 : 1;
|
|
136
|
+
let value = spec.value;
|
|
137
|
+
for (let index = 0; index < times; index += 1) {
|
|
138
|
+
const next = clamp(spec.step(value, direction));
|
|
139
|
+
if (Math.abs(next - value) < 1e-12) break;
|
|
140
|
+
value = next;
|
|
141
|
+
}
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const LEFT = new Set(["\u001b[D", "\u001bOD", "h", "-", "_"]);
|
|
146
|
+
const RIGHT = new Set(["\u001b[C", "\u001bOC", "l", "+", "="]);
|
|
147
|
+
const COARSE_LEFT = new Set(["\u001b[1;2D", "{"]);
|
|
148
|
+
const COARSE_RIGHT = new Set(["\u001b[1;2C", "}"]);
|
|
149
|
+
const FINE_LEFT = new Set(["\u001b[1;3D", "\u001b[1;5D", "\u001bb", "["]);
|
|
150
|
+
const FINE_RIGHT = new Set(["\u001b[1;3C", "\u001b[1;5C", "\u001bf", "]"]);
|
|
151
|
+
const UP = new Set(["\u001b[A", "\u001bOA", "k", "\u001b[Z"]);
|
|
152
|
+
const DOWN = new Set(["\u001b[B", "\u001bOB", "j", "\t"]);
|
|
153
|
+
const PAGE_UP = "\u001b[5~";
|
|
154
|
+
const PAGE_DOWN = "\u001b[6~";
|
|
155
|
+
const HOME = new Set(["\u001b[H", "\u001bOH", "\u001b[1~"]);
|
|
156
|
+
const END = new Set(["\u001b[F", "\u001bOF", "\u001b[4~"]);
|
|
157
|
+
const ENTER = new Set(["\r", "\n"]);
|
|
158
|
+
const BACKSPACE = new Set(["\u007f", "\b"]);
|
|
159
|
+
|
|
160
|
+
export interface FaderKeyOptions {
|
|
161
|
+
/** Edits are staged (Enter keeps, Esc reverts). */
|
|
162
|
+
dirty: boolean;
|
|
163
|
+
/** The window hosts the audition loop (space, a, c). */
|
|
164
|
+
audition: boolean;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Index of the focused field (the first when its label is gone). */
|
|
168
|
+
export function focusIndex(
|
|
169
|
+
state: FaderState,
|
|
170
|
+
fields: readonly FaderSpec[],
|
|
171
|
+
): number {
|
|
172
|
+
return Math.max(
|
|
173
|
+
0,
|
|
174
|
+
fields.findIndex((field) => field.label === state.label),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function setNumber(spec: FaderNumber, value: number): FaderResult {
|
|
179
|
+
if (spec.value !== undefined && Math.abs(value - spec.value) < 1e-9)
|
|
180
|
+
return { type: "handled" };
|
|
181
|
+
return {
|
|
182
|
+
type: "set",
|
|
183
|
+
command: spec.command(value),
|
|
184
|
+
key: faderKey(spec.label),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function setChoice(spec: FaderChoice, index: number): FaderResult {
|
|
189
|
+
const option =
|
|
190
|
+
spec.options[Math.min(spec.options.length - 1, Math.max(0, index))];
|
|
191
|
+
if (option === undefined || option === spec.value) return { type: "handled" };
|
|
192
|
+
return {
|
|
193
|
+
type: "set",
|
|
194
|
+
command: spec.command(option),
|
|
195
|
+
key: faderKey(spec.label),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** One key in fader mode. Mutates `state` (focus, typing). */
|
|
200
|
+
export function faderKeyPress(
|
|
201
|
+
state: FaderState,
|
|
202
|
+
fields: readonly FaderSpec[],
|
|
203
|
+
value: string,
|
|
204
|
+
options: FaderKeyOptions,
|
|
205
|
+
): FaderResult {
|
|
206
|
+
if (fields.length === 0) return { type: "close" };
|
|
207
|
+
// Ctrl-C, Ctrl-L and `?` (the keys panel) belong to the app.
|
|
208
|
+
if (value === "\u0003" || value === "\u000c") return { type: "pass" };
|
|
209
|
+
if (value === "?" && state.typing === undefined) return { type: "pass" };
|
|
210
|
+
const index = focusIndex(state, fields);
|
|
211
|
+
const field = fields[index]!;
|
|
212
|
+
state.label = field.label;
|
|
213
|
+
if (state.typing !== undefined) {
|
|
214
|
+
if (value === "\u001b") {
|
|
215
|
+
state.typing = undefined;
|
|
216
|
+
return { type: "handled" };
|
|
217
|
+
}
|
|
218
|
+
if (BACKSPACE.has(value)) {
|
|
219
|
+
state.typing = state.typing.slice(0, -1);
|
|
220
|
+
if (!state.typing) state.typing = undefined;
|
|
221
|
+
return { type: "handled" };
|
|
222
|
+
}
|
|
223
|
+
if (ENTER.has(value) || value === "\t") {
|
|
224
|
+
const text = state.typing;
|
|
225
|
+
state.typing = undefined;
|
|
226
|
+
const command = field.kind === "number" ? field.parse(text) : undefined;
|
|
227
|
+
return command
|
|
228
|
+
? { type: "set", command, key: faderKey(field.label) }
|
|
229
|
+
: { type: "handled" };
|
|
230
|
+
}
|
|
231
|
+
if (/^[0-9.\-]$/.test(value)) {
|
|
232
|
+
state.typing = (state.typing + value).slice(0, 16);
|
|
233
|
+
return { type: "handled" };
|
|
234
|
+
}
|
|
235
|
+
return { type: "handled" };
|
|
236
|
+
}
|
|
237
|
+
if (value === "\u001b")
|
|
238
|
+
return options.dirty ? { type: "revert" } : { type: "close" };
|
|
239
|
+
if (ENTER.has(value))
|
|
240
|
+
return options.dirty ? { type: "keep" } : { type: "close" };
|
|
241
|
+
const move = UP.has(value) ? -1 : DOWN.has(value) ? 1 : 0;
|
|
242
|
+
if (move) {
|
|
243
|
+
state.label = fields[(index + move + fields.length) % fields.length]!.label;
|
|
244
|
+
return { type: "handled" };
|
|
245
|
+
}
|
|
246
|
+
if (options.audition) {
|
|
247
|
+
if (value === " ") return { type: "audition", key: "loop" };
|
|
248
|
+
if (value === "a") return { type: "audition", key: "ab" };
|
|
249
|
+
if (value === "c") return { type: "audition", key: "context" };
|
|
250
|
+
}
|
|
251
|
+
if (field.kind === "choice") {
|
|
252
|
+
const at = Math.max(0, field.options.indexOf(field.value));
|
|
253
|
+
if (LEFT.has(value) || FINE_LEFT.has(value) || COARSE_LEFT.has(value))
|
|
254
|
+
return setChoice(field, at - 1);
|
|
255
|
+
if (RIGHT.has(value) || FINE_RIGHT.has(value) || COARSE_RIGHT.has(value))
|
|
256
|
+
return setChoice(field, at + 1);
|
|
257
|
+
if (HOME.has(value) || value === PAGE_DOWN) return setChoice(field, 0);
|
|
258
|
+
if (END.has(value) || value === PAGE_UP)
|
|
259
|
+
return setChoice(field, field.options.length - 1);
|
|
260
|
+
// 1…9 picks an option by number.
|
|
261
|
+
if (/^[1-9]$/.test(value)) return setChoice(field, Number(value) - 1);
|
|
262
|
+
return { type: "handled" };
|
|
263
|
+
}
|
|
264
|
+
const step = (direction: 1 | -1, size: StepSize) =>
|
|
265
|
+
setNumber(field, stepValue(field, direction, size));
|
|
266
|
+
if (LEFT.has(value)) return step(-1, "normal");
|
|
267
|
+
if (RIGHT.has(value)) return step(1, "normal");
|
|
268
|
+
if (COARSE_LEFT.has(value)) return step(-1, "coarse");
|
|
269
|
+
if (COARSE_RIGHT.has(value)) return step(1, "coarse");
|
|
270
|
+
if (FINE_LEFT.has(value)) return step(-1, "fine");
|
|
271
|
+
if (FINE_RIGHT.has(value)) return step(1, "fine");
|
|
272
|
+
if (value === PAGE_UP) return step(1, "page");
|
|
273
|
+
if (value === PAGE_DOWN) return step(-1, "page");
|
|
274
|
+
if (HOME.has(value)) return setNumber(field, field.min);
|
|
275
|
+
if (END.has(value)) return setNumber(field, field.max);
|
|
276
|
+
if (value === "0" || value === "d") {
|
|
277
|
+
const reset =
|
|
278
|
+
field.reset ??
|
|
279
|
+
(field.start !== undefined ? field.command(field.start) : undefined);
|
|
280
|
+
return reset && field.value !== undefined
|
|
281
|
+
? { type: "set", command: reset, key: faderKey(field.label) }
|
|
282
|
+
: { type: "handled" };
|
|
283
|
+
}
|
|
284
|
+
if (/^[1-9.]$/.test(value)) {
|
|
285
|
+
state.typing = value === "." ? "0." : value;
|
|
286
|
+
return { type: "handled" };
|
|
287
|
+
}
|
|
288
|
+
return { type: "handled" };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** A click or drag on a field's bar at `position` (0…1). */
|
|
292
|
+
export function faderSetPosition(
|
|
293
|
+
state: FaderState,
|
|
294
|
+
field: FaderSpec | undefined,
|
|
295
|
+
position: number,
|
|
296
|
+
): FaderResult {
|
|
297
|
+
if (!field) return { type: "handled" };
|
|
298
|
+
state.label = field.label;
|
|
299
|
+
state.typing = undefined;
|
|
300
|
+
if (field.kind === "choice")
|
|
301
|
+
return setChoice(
|
|
302
|
+
field,
|
|
303
|
+
Math.round(position * Math.max(0, field.options.length - 1)),
|
|
304
|
+
);
|
|
305
|
+
return setNumber(field, faderValueAt(field, position));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** A [−] / [+] click or a wheel notch on a field. */
|
|
309
|
+
export function faderStep(
|
|
310
|
+
state: FaderState,
|
|
311
|
+
field: FaderSpec | undefined,
|
|
312
|
+
direction: 1 | -1,
|
|
313
|
+
size: StepSize = "normal",
|
|
314
|
+
): FaderResult {
|
|
315
|
+
if (!field) return { type: "handled" };
|
|
316
|
+
state.label = field.label;
|
|
317
|
+
state.typing = undefined;
|
|
318
|
+
if (field.kind === "choice") {
|
|
319
|
+
const at = Math.max(0, field.options.indexOf(field.value));
|
|
320
|
+
return setChoice(field, at + direction);
|
|
321
|
+
}
|
|
322
|
+
return setNumber(field, stepValue(field, direction, size));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** A click on one option of a choice field. */
|
|
326
|
+
export function faderChoose(
|
|
327
|
+
state: FaderState,
|
|
328
|
+
field: FaderSpec | undefined,
|
|
329
|
+
option: number,
|
|
330
|
+
): FaderResult {
|
|
331
|
+
if (!field || field.kind !== "choice") return { type: "handled" };
|
|
332
|
+
state.label = field.label;
|
|
333
|
+
state.typing = undefined;
|
|
334
|
+
return setChoice(field, option);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const HINT =
|
|
338
|
+
"←→ adjust · ⇧←→ coarse · [ ] fine · ↑↓ param · 0-9 type · d default · enter keep · esc revert";
|
|
339
|
+
|
|
340
|
+
/** The drawer's paint model for `fields` (staged) beside `committed`. */
|
|
341
|
+
export function drawerView(
|
|
342
|
+
state: FaderState,
|
|
343
|
+
fields: readonly FaderSpec[],
|
|
344
|
+
committed: readonly FaderSpec[],
|
|
345
|
+
options: {
|
|
346
|
+
title: string;
|
|
347
|
+
dirty: boolean;
|
|
348
|
+
status?: string | undefined;
|
|
349
|
+
hint?: string;
|
|
350
|
+
},
|
|
351
|
+
): DrawerView {
|
|
352
|
+
const before = new Map(committed.map((field) => [field.label, field]));
|
|
353
|
+
const drawn: DrawerField[] = fields.map((field) => {
|
|
354
|
+
const was = before.get(field.label);
|
|
355
|
+
if (field.kind === "choice") {
|
|
356
|
+
const index = Math.max(0, field.options.indexOf(field.value));
|
|
357
|
+
const committedIndex =
|
|
358
|
+
was?.kind === "choice" && was.value !== field.value
|
|
359
|
+
? field.options.indexOf(was.value)
|
|
360
|
+
: undefined;
|
|
361
|
+
return {
|
|
362
|
+
kind: "choice",
|
|
363
|
+
label: field.label,
|
|
364
|
+
options: field.options,
|
|
365
|
+
index,
|
|
366
|
+
committedIndex:
|
|
367
|
+
committedIndex !== undefined && committedIndex >= 0
|
|
368
|
+
? committedIndex
|
|
369
|
+
: undefined,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
const text =
|
|
373
|
+
field.value === undefined
|
|
374
|
+
? (field.off ?? "off")
|
|
375
|
+
: field.format(field.value);
|
|
376
|
+
const wasText =
|
|
377
|
+
was?.kind === "number"
|
|
378
|
+
? was.value === undefined
|
|
379
|
+
? (was.off ?? "off")
|
|
380
|
+
: was.format(was.value)
|
|
381
|
+
: undefined;
|
|
382
|
+
const changed = wasText !== undefined && wasText !== text;
|
|
383
|
+
return {
|
|
384
|
+
kind: "number",
|
|
385
|
+
label: field.label,
|
|
386
|
+
text,
|
|
387
|
+
committed: changed ? wasText : undefined,
|
|
388
|
+
position:
|
|
389
|
+
field.value === undefined
|
|
390
|
+
? undefined
|
|
391
|
+
: faderPosition(field, field.value),
|
|
392
|
+
committedPosition:
|
|
393
|
+
changed && was?.kind === "number" && was.value !== undefined
|
|
394
|
+
? faderPosition(field, was.value)
|
|
395
|
+
: undefined,
|
|
396
|
+
minText: field.format(field.min),
|
|
397
|
+
maxText: field.format(field.max),
|
|
398
|
+
};
|
|
399
|
+
});
|
|
400
|
+
return {
|
|
401
|
+
title: options.title,
|
|
402
|
+
fields: drawn,
|
|
403
|
+
focus: focusIndex(state, fields),
|
|
404
|
+
typing: state.typing,
|
|
405
|
+
dirty: options.dirty,
|
|
406
|
+
status: options.status,
|
|
407
|
+
hint: options.hint ?? HINT,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ctrl-K rows for the granular instrument (core/granular.ts): the
|
|
3
|
+
* Sound > Granular subsection and the Granular group of Sound > browse
|
|
4
|
+
* sounds. Every row is a `grain …` command, so the menu teaches the command
|
|
5
|
+
* and an edit is one revision, one receipt and one undo step.
|
|
6
|
+
*/
|
|
7
|
+
import { isDrumInstrument } from "../../core/drums.ts";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_GRANULAR_SOURCE,
|
|
10
|
+
GRANULAR_PARAMS,
|
|
11
|
+
GRANULAR_PRESET_NAMES,
|
|
12
|
+
GRANULAR_PRESETS,
|
|
13
|
+
granularSourceLabel,
|
|
14
|
+
isGranularInstrument,
|
|
15
|
+
parseSynthSource,
|
|
16
|
+
resolveGranular,
|
|
17
|
+
type GranularSettings,
|
|
18
|
+
} from "../../core/granular.ts";
|
|
19
|
+
import type { NumberParam } from "../../core/params.ts";
|
|
20
|
+
import type { Track } from "../../core/score.ts";
|
|
21
|
+
|
|
22
|
+
/** The subset of the menu's node shape these rows use. */
|
|
23
|
+
export type GranularMenuNode =
|
|
24
|
+
| Readonly<{
|
|
25
|
+
kind: "number";
|
|
26
|
+
label: string;
|
|
27
|
+
help: string;
|
|
28
|
+
value: number | undefined;
|
|
29
|
+
start: number;
|
|
30
|
+
off: string;
|
|
31
|
+
min: number;
|
|
32
|
+
max: number;
|
|
33
|
+
step: (value: number, direction: 1 | -1) => number;
|
|
34
|
+
format: (value: number) => string;
|
|
35
|
+
command: (value: number) => string;
|
|
36
|
+
reset: string;
|
|
37
|
+
}>
|
|
38
|
+
| Readonly<{
|
|
39
|
+
kind: "toggle";
|
|
40
|
+
label: string;
|
|
41
|
+
help: string;
|
|
42
|
+
value: boolean;
|
|
43
|
+
command: (value: boolean) => string;
|
|
44
|
+
}>
|
|
45
|
+
| Readonly<{
|
|
46
|
+
kind: "choice";
|
|
47
|
+
label: string;
|
|
48
|
+
help: string;
|
|
49
|
+
value: string;
|
|
50
|
+
options: readonly string[];
|
|
51
|
+
command: (option: string) => string;
|
|
52
|
+
}>
|
|
53
|
+
| Readonly<{
|
|
54
|
+
kind: "entry";
|
|
55
|
+
label: string;
|
|
56
|
+
help: string;
|
|
57
|
+
value: string;
|
|
58
|
+
placeholder: string;
|
|
59
|
+
command: (text: string) => string | undefined;
|
|
60
|
+
example: string;
|
|
61
|
+
}>
|
|
62
|
+
| Readonly<{ kind: "action"; label: string; help: string; command: string }>
|
|
63
|
+
| Readonly<{ kind: "info"; label: string; value: string; help?: string }>;
|
|
64
|
+
|
|
65
|
+
/** Menu labels for the stored keys, in the review's row order. */
|
|
66
|
+
export const GRANULAR_MENU_ROWS: readonly (readonly [string, string])[] =
|
|
67
|
+
Object.freeze([
|
|
68
|
+
["begin", "begin"],
|
|
69
|
+
["end", "end"],
|
|
70
|
+
["root", "root note"],
|
|
71
|
+
["pos", "position"],
|
|
72
|
+
["scan", "scan"],
|
|
73
|
+
["grain", "grain"],
|
|
74
|
+
["overlap", "overlap"],
|
|
75
|
+
["jitter", "jitter"],
|
|
76
|
+
["spray", "spray"],
|
|
77
|
+
["pitch", "pitch"],
|
|
78
|
+
["detune", "detune"],
|
|
79
|
+
["shimmer", "shimmer"],
|
|
80
|
+
["shimint", "shimmer interval"],
|
|
81
|
+
["repeat", "repeat"],
|
|
82
|
+
["hold", "hold"],
|
|
83
|
+
["drift", "drift"],
|
|
84
|
+
["drate", "drift rate"],
|
|
85
|
+
["spread", "spread"],
|
|
86
|
+
["window", "window"],
|
|
87
|
+
["reverse", "reverse"],
|
|
88
|
+
["freeze", "freeze"],
|
|
89
|
+
["attack", "attack"],
|
|
90
|
+
["release", "release"],
|
|
91
|
+
["veltone", "velocity tone"],
|
|
92
|
+
["gain", "gain"],
|
|
93
|
+
["seed", "seed"],
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
/** Presets that need a resampled phrase to sound as named. */
|
|
97
|
+
const PHRASE_PRESETS: ReadonlySet<string> = new Set([
|
|
98
|
+
"microloop",
|
|
99
|
+
"sparkle",
|
|
100
|
+
"backwards",
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
function text(value: number): string {
|
|
104
|
+
return Number(value.toFixed(4)).toString();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function stepper(
|
|
108
|
+
spec: NumberParam,
|
|
109
|
+
): (value: number, direction: 1 | -1) => number {
|
|
110
|
+
if (spec.step === "log")
|
|
111
|
+
return (value, direction) =>
|
|
112
|
+
Math.min(
|
|
113
|
+
spec.max,
|
|
114
|
+
Math.max(
|
|
115
|
+
spec.min,
|
|
116
|
+
Number((Math.max(value, 1e-3) * 2 ** (direction / 6)).toFixed(4)),
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
const size = spec.step;
|
|
120
|
+
return (value, direction) =>
|
|
121
|
+
Math.min(
|
|
122
|
+
spec.max,
|
|
123
|
+
Math.max(
|
|
124
|
+
spec.min,
|
|
125
|
+
Number(
|
|
126
|
+
(Math.round((value + size * direction) / size) * size).toFixed(6),
|
|
127
|
+
),
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Whether a track shows the granular subsection, and how it is labelled. */
|
|
133
|
+
export function granularMenuLabel(
|
|
134
|
+
track: Track | undefined,
|
|
135
|
+
): string | undefined {
|
|
136
|
+
if (!track || track.kit || isDrumInstrument(track.instrument))
|
|
137
|
+
return undefined;
|
|
138
|
+
return isGranularInstrument(track.instrument)
|
|
139
|
+
? "granular"
|
|
140
|
+
: "granular (convert)";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** One-line state for the subsection's detail column. */
|
|
144
|
+
export function granularMenuDetail(track: Track): string {
|
|
145
|
+
if (!isGranularInstrument(track.instrument))
|
|
146
|
+
return track.sampler
|
|
147
|
+
? "grain this sampler's voice"
|
|
148
|
+
: "grain this track's synth";
|
|
149
|
+
return `${track.granular?.preset ?? "default"} · ${granularSourceLabel(track.granular?.src)}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Sound > Granular: on a granular track the preset, source and every
|
|
154
|
+
* parameter; elsewhere the presets that convert the track (its own sound
|
|
155
|
+
* becomes the source) plus a plain `grain on`.
|
|
156
|
+
*/
|
|
157
|
+
export function granularMenuNodes(
|
|
158
|
+
track: Track | undefined,
|
|
159
|
+
): GranularMenuNode[] {
|
|
160
|
+
if (!track || !granularMenuLabel(track)) return [];
|
|
161
|
+
if (!isGranularInstrument(track.instrument))
|
|
162
|
+
return [
|
|
163
|
+
{
|
|
164
|
+
kind: "action",
|
|
165
|
+
label: "grain this sound",
|
|
166
|
+
help: track.sampler
|
|
167
|
+
? "turn this sampler's first voice into a grain cloud (grain off goes back)"
|
|
168
|
+
: "turn this track's synth into a grain cloud (grain off goes back)",
|
|
169
|
+
command: "grain on",
|
|
170
|
+
},
|
|
171
|
+
...GRANULAR_PRESET_NAMES.map((name): GranularMenuNode => ({
|
|
172
|
+
kind: "action",
|
|
173
|
+
label: `${name.padEnd(10)} ${GRANULAR_PRESETS[name].doc}`,
|
|
174
|
+
help: `grain ${name}: this track's sound through the ${name} preset`,
|
|
175
|
+
command: `grain ${name}`,
|
|
176
|
+
})),
|
|
177
|
+
];
|
|
178
|
+
const settings = resolveGranular(track.granular);
|
|
179
|
+
const own = track.granular ?? {};
|
|
180
|
+
const preset = track.granular?.preset;
|
|
181
|
+
const builtIn =
|
|
182
|
+
typeof settings.src === "string" &&
|
|
183
|
+
parseSynthSource(settings.src) !== undefined;
|
|
184
|
+
const nodes: GranularMenuNode[] = [
|
|
185
|
+
{
|
|
186
|
+
kind: "choice",
|
|
187
|
+
label: "preset",
|
|
188
|
+
help:
|
|
189
|
+
preset && PHRASE_PRESETS.has(preset) && builtIn
|
|
190
|
+
? `${GRANULAR_PRESETS[preset].doc} (on a resample; a texture on a synth source)`
|
|
191
|
+
: preset
|
|
192
|
+
? GRANULAR_PRESETS[preset].doc
|
|
193
|
+
: "left/right cycles presets; space previews",
|
|
194
|
+
value: preset ?? "",
|
|
195
|
+
options: GRANULAR_PRESET_NAMES,
|
|
196
|
+
command: (name) => `grain ${name}`,
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
kind: "entry",
|
|
200
|
+
label: "source",
|
|
201
|
+
help: "synth:<preset>[@note] or voice <V> of this track's sampler",
|
|
202
|
+
value: granularSourceLabel(track.granular?.src),
|
|
203
|
+
placeholder: DEFAULT_GRANULAR_SOURCE,
|
|
204
|
+
command: (value) => {
|
|
205
|
+
const trimmed = value.trim();
|
|
206
|
+
if (!trimmed) return undefined;
|
|
207
|
+
return /^voice\s+\S+$/.test(trimmed)
|
|
208
|
+
? `grain src ${trimmed}`
|
|
209
|
+
: `grain src ${trimmed.startsWith("synth:") ? trimmed : `synth:${trimmed}`}`;
|
|
210
|
+
},
|
|
211
|
+
example: "grain src synth:bell",
|
|
212
|
+
},
|
|
213
|
+
];
|
|
214
|
+
for (const [key, label] of GRANULAR_MENU_ROWS) {
|
|
215
|
+
const spec = GRANULAR_PARAMS[key]!;
|
|
216
|
+
const stored = (own as Record<string, unknown>)[key];
|
|
217
|
+
const current = settings[key as keyof GranularSettings];
|
|
218
|
+
if (spec.kind === "number") {
|
|
219
|
+
const base = typeof current === "number" ? current : spec.default;
|
|
220
|
+
nodes.push({
|
|
221
|
+
kind: "number",
|
|
222
|
+
label: spec.unit ? `${label} (${spec.unit})` : label,
|
|
223
|
+
help: spec.doc,
|
|
224
|
+
value: typeof stored === "number" ? stored : undefined,
|
|
225
|
+
start: base,
|
|
226
|
+
off: text(base),
|
|
227
|
+
min: spec.min,
|
|
228
|
+
max: spec.max,
|
|
229
|
+
step: stepper(spec),
|
|
230
|
+
format: text,
|
|
231
|
+
command: (value) => `grain ${key} ${text(value)}`,
|
|
232
|
+
reset: `grain ${key} off`,
|
|
233
|
+
});
|
|
234
|
+
} else if (spec.kind === "enum")
|
|
235
|
+
nodes.push({
|
|
236
|
+
kind: "choice",
|
|
237
|
+
label,
|
|
238
|
+
help: spec.doc,
|
|
239
|
+
value: String(current),
|
|
240
|
+
options: spec.values,
|
|
241
|
+
command: (option) => `grain ${key} ${option}`,
|
|
242
|
+
});
|
|
243
|
+
else if (spec.kind === "boolean")
|
|
244
|
+
nodes.push({
|
|
245
|
+
kind: "toggle",
|
|
246
|
+
label,
|
|
247
|
+
help: spec.doc,
|
|
248
|
+
value: current === true,
|
|
249
|
+
command: (on) => `grain ${key} ${on ? "on" : "off"}`,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
if (Object.keys(own).some((key) => key !== "preset" && key !== "src"))
|
|
253
|
+
nodes.push({
|
|
254
|
+
kind: "action",
|
|
255
|
+
label: "reset to preset",
|
|
256
|
+
help: "drop this track's grain overrides, keep the preset and source",
|
|
257
|
+
command: "grain reset",
|
|
258
|
+
});
|
|
259
|
+
nodes.push({
|
|
260
|
+
kind: "action",
|
|
261
|
+
label: "grain off",
|
|
262
|
+
help: "back to this track's previous voice (settings are kept)",
|
|
263
|
+
command: "grain off",
|
|
264
|
+
});
|
|
265
|
+
return nodes;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Sound > browse sounds > Granular: one row per preset. */
|
|
269
|
+
export function granularBrowseNodes(): GranularMenuNode[] {
|
|
270
|
+
return GRANULAR_PRESET_NAMES.map((name) => ({
|
|
271
|
+
kind: "action",
|
|
272
|
+
label: `${name.padEnd(10)} ${GRANULAR_PRESETS[name].doc}`,
|
|
273
|
+
help: PHRASE_PRESETS.has(name)
|
|
274
|
+
? `grain ${name} (comes into its own on a resampled phrase)`
|
|
275
|
+
: `grain ${name}: the built-in or this track's sound as a grain cloud`,
|
|
276
|
+
command: `grain ${name}`,
|
|
277
|
+
}));
|
|
278
|
+
}
|