@hraness/dawg 0.4.0 → 0.5.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 +86 -0
- package/DAWG.md +300 -35
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +37 -12
- package/core/expression.ts +1241 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +536 -56
- package/core/sdk/eval-child.ts +38 -3
- package/core/sdk/print.ts +445 -14
- package/core/sdk/v1.ts +1709 -23
- package/core/sections.ts +2046 -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 +27 -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 +25 -0
- package/guides/tempo.md +26 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +2 -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/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +29 -4
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +21 -3
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +36 -2
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +2 -0
- package/src/audio/arrange.ts +471 -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/effects/bus.ts +5 -4
- package/src/audio/effects/chain.ts +12 -2
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/convolution.ts +43 -5
- 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/space.ts +197 -50
- package/src/audio/engine.ts +175 -20
- package/src/audio/live.ts +33 -6
- package/src/audio/loudness.ts +551 -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 +87 -16
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/sampler.ts +108 -19
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +86 -0
- package/src/audio/wav.ts +448 -59
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +934 -0
- package/src/commands/help.ts +281 -24
- package/src/commands/master.ts +361 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +964 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +826 -47
- package/src/render.ts +109 -7
- 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 +58 -5
- package/src/tui/euclid.ts +50 -6
- package/src/tui/fader.ts +409 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +656 -16
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +95 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +414 -41
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +79 -12
- 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
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sound › Performance: how the focused track plays its notes. Every row
|
|
3
|
+
* runs the same prompt command a user could type (src/commands/expression.ts),
|
|
4
|
+
* so the menu, the prompt and the agent share one grammar.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
ARTICULATIONS,
|
|
8
|
+
DEFAULT_FIXED_VELOCITY,
|
|
9
|
+
DEFAULT_GLIDE_SECONDS,
|
|
10
|
+
EXPRESSION_LIMITS,
|
|
11
|
+
GLIDE_MODES,
|
|
12
|
+
VELOCITY_CURVES,
|
|
13
|
+
} from "../../core/expression.ts";
|
|
14
|
+
import type { Note, Track } from "../../core/score.ts";
|
|
15
|
+
import {
|
|
16
|
+
BEND_SHAPES,
|
|
17
|
+
DEFAULT_HUMANIZE,
|
|
18
|
+
describeHumanize,
|
|
19
|
+
parseExpressionCommand,
|
|
20
|
+
} from "../commands/expression.ts";
|
|
21
|
+
import type { MenuContext, MenuNode } from "./menu.ts";
|
|
22
|
+
|
|
23
|
+
const round = (value: number, places = 3) =>
|
|
24
|
+
Math.round(value * 10 ** places) / 10 ** places;
|
|
25
|
+
|
|
26
|
+
/** One value every note shares, `mixed`, or `fallback` when none set it. */
|
|
27
|
+
function shared(
|
|
28
|
+
notes: readonly Note[],
|
|
29
|
+
read: (note: Note) => string | undefined,
|
|
30
|
+
fallback: string,
|
|
31
|
+
): string {
|
|
32
|
+
const values = new Set(notes.map((note) => read(note) ?? fallback));
|
|
33
|
+
if (values.size === 0) return fallback;
|
|
34
|
+
return values.size === 1 ? [...values][0]! : "mixed";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function bendName(note: Note): string | undefined {
|
|
38
|
+
if (!note.bend) return undefined;
|
|
39
|
+
const text = JSON.stringify(note.bend);
|
|
40
|
+
return (
|
|
41
|
+
Object.entries(BEND_SHAPES).find(
|
|
42
|
+
([, points]) => JSON.stringify(points) === text,
|
|
43
|
+
)?.[0] ?? "custom"
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The menu's one-line summary of a track's performance, for its row. */
|
|
48
|
+
export function performanceDetail(track: Track | undefined): string {
|
|
49
|
+
if (!track) return "—";
|
|
50
|
+
const parts = [
|
|
51
|
+
track.glide ? `glide ${track.glide.mode}` : "",
|
|
52
|
+
track.pedal?.length ? "pedal" : "",
|
|
53
|
+
track.velocityCurve ? `vel ${track.velocityCurve.curve}` : "",
|
|
54
|
+
track.humanize ? "humanized" : "",
|
|
55
|
+
].filter(Boolean);
|
|
56
|
+
return parts.length ? parts.join(" · ") : "as written";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function performanceNodes(context: MenuContext): MenuNode[] {
|
|
60
|
+
const track = context.score.tracks.find(
|
|
61
|
+
(item) => item.id === context.trackId,
|
|
62
|
+
);
|
|
63
|
+
if (!track) return [];
|
|
64
|
+
const notes = context.score.notes.filter((note) => note.trackId === track.id);
|
|
65
|
+
const glide = track.glide;
|
|
66
|
+
const curve = track.velocityCurve;
|
|
67
|
+
const humanize = track.humanize;
|
|
68
|
+
const humanizeCommand = (timing: number, velocity: number, length: number) =>
|
|
69
|
+
`humanize ${round(timing)} ${round(velocity)} ${round(length)}`;
|
|
70
|
+
const timing = humanize?.timing ?? 0;
|
|
71
|
+
const velocity = humanize?.velocity ?? 0;
|
|
72
|
+
const length = humanize?.length ?? 0;
|
|
73
|
+
const pedalLabel = track.pedal?.length
|
|
74
|
+
? `${track.pedal.length} events`
|
|
75
|
+
: "off";
|
|
76
|
+
const nodes: MenuNode[] = [
|
|
77
|
+
{
|
|
78
|
+
kind: "choice",
|
|
79
|
+
label: "articulation",
|
|
80
|
+
value: shared(notes, (note) => note.articulation, "none"),
|
|
81
|
+
options: ["none", ...ARTICULATIONS],
|
|
82
|
+
command: (option) => `art ${option === "none" ? "off" : option}`,
|
|
83
|
+
help: "every note on the track: staccato halves the length, ghost plays quiet",
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
kind: "number",
|
|
87
|
+
label: "glide (ms)",
|
|
88
|
+
value: glide?.time,
|
|
89
|
+
min: 0,
|
|
90
|
+
max: EXPRESSION_LIMITS.maxGlideSeconds,
|
|
91
|
+
step: (value, direction) =>
|
|
92
|
+
Math.min(
|
|
93
|
+
EXPRESSION_LIMITS.maxGlideSeconds,
|
|
94
|
+
Math.max(0, round(value + 0.01 * direction, 2)),
|
|
95
|
+
),
|
|
96
|
+
format: (value) => `${Math.round(value * 1000)} ms`,
|
|
97
|
+
command: (value) => `glide ${Math.round(value * 1000)}ms`,
|
|
98
|
+
off: "off",
|
|
99
|
+
start: DEFAULT_GLIDE_SECONDS,
|
|
100
|
+
reset: "glide off",
|
|
101
|
+
help: "portamento into each note from the one before",
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
kind: "choice",
|
|
105
|
+
label: "glide mode",
|
|
106
|
+
value: glide?.mode ?? "off",
|
|
107
|
+
options: ["off", ...GLIDE_MODES],
|
|
108
|
+
command: (option) => (option === "off" ? "glide off" : `glide ${option}`),
|
|
109
|
+
help: "legato: slide only into overlapping notes (TB-303); mono: always; poly: every voice",
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
kind: "choice",
|
|
113
|
+
label: "bend",
|
|
114
|
+
value: shared(notes, bendName, "none"),
|
|
115
|
+
options: ["none", ...Object.keys(BEND_SHAPES)],
|
|
116
|
+
command: (option) => `bend ${option === "none" ? "off" : option}`,
|
|
117
|
+
help: "a pitch curve over every note; `bend` in the prompt takes points",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
kind: "entry",
|
|
121
|
+
label: "vibrato",
|
|
122
|
+
value: shared(
|
|
123
|
+
notes,
|
|
124
|
+
(note) =>
|
|
125
|
+
note.vibrato
|
|
126
|
+
? `${note.vibrato.rate} Hz ${note.vibrato.depth}¢`
|
|
127
|
+
: undefined,
|
|
128
|
+
"none",
|
|
129
|
+
),
|
|
130
|
+
placeholder: "rate Hz, depth cents, delay s: 5.5 25 0.2",
|
|
131
|
+
command: (text) => {
|
|
132
|
+
const command = text.trim() ? `vibrato ${text.trim()}` : "vibrato off";
|
|
133
|
+
return parseExpressionCommand(command) ? command : undefined;
|
|
134
|
+
},
|
|
135
|
+
example: "vibrato 5.5 25 0.2",
|
|
136
|
+
help: "replaces the synth's vib on every note on the track",
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
kind: "choice",
|
|
140
|
+
label: "sustain pedal",
|
|
141
|
+
value: pedalLabel,
|
|
142
|
+
options: ["off", ...(pedalLabel === "off" ? [] : [pedalLabel]), "bars"],
|
|
143
|
+
command: (option) =>
|
|
144
|
+
option === "bars"
|
|
145
|
+
? "pedal bars"
|
|
146
|
+
: option === "off"
|
|
147
|
+
? "pedal off"
|
|
148
|
+
: "pedal",
|
|
149
|
+
help: "bars replaces the lane with a re-pedal on each downbeat; in play mode press Tab (latch) or hold Shift while recording",
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
kind: "choice",
|
|
153
|
+
label: "velocity curve",
|
|
154
|
+
value: curve?.curve ?? "linear",
|
|
155
|
+
options: VELOCITY_CURVES,
|
|
156
|
+
command: (option) => `velcurve ${option}`,
|
|
157
|
+
help: "soft lifts quiet notes, hard deepens them, fixed plays every note the same",
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
if (curve?.curve === "fixed")
|
|
161
|
+
nodes.push({
|
|
162
|
+
kind: "number",
|
|
163
|
+
label: "fixed velocity",
|
|
164
|
+
value: curve.fixed ?? DEFAULT_FIXED_VELOCITY,
|
|
165
|
+
min: 0,
|
|
166
|
+
max: 1,
|
|
167
|
+
step: (value, direction) =>
|
|
168
|
+
Math.min(1, Math.max(0, round(value + 0.05 * direction, 2))),
|
|
169
|
+
format: (value) => String(round(value, 2)),
|
|
170
|
+
command: (value) => `velcurve fixed ${round(value, 2)}`,
|
|
171
|
+
reset: `velcurve fixed ${DEFAULT_FIXED_VELOCITY}`,
|
|
172
|
+
});
|
|
173
|
+
nodes.push(
|
|
174
|
+
{
|
|
175
|
+
kind: "number",
|
|
176
|
+
label: "humanize timing (ms)",
|
|
177
|
+
value: humanize ? timing : undefined,
|
|
178
|
+
min: 0,
|
|
179
|
+
max: EXPRESSION_LIMITS.maxHumanizeTimingMs,
|
|
180
|
+
step: (value, direction) =>
|
|
181
|
+
Math.min(
|
|
182
|
+
EXPRESSION_LIMITS.maxHumanizeTimingMs,
|
|
183
|
+
Math.max(0, value + direction),
|
|
184
|
+
),
|
|
185
|
+
format: (value) => `±${round(value)} ms`,
|
|
186
|
+
command: (value) => humanizeCommand(value, velocity, length),
|
|
187
|
+
off: "off",
|
|
188
|
+
start: DEFAULT_HUMANIZE.timing,
|
|
189
|
+
reset: "humanize off",
|
|
190
|
+
help: `seeded drift applied at render; the score stays clean (${describeHumanize(humanize)})`,
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
kind: "number",
|
|
194
|
+
label: "humanize velocity (%)",
|
|
195
|
+
value: humanize ? velocity : undefined,
|
|
196
|
+
min: 0,
|
|
197
|
+
max: EXPRESSION_LIMITS.maxHumanizePercent,
|
|
198
|
+
step: (value, direction) =>
|
|
199
|
+
Math.min(
|
|
200
|
+
EXPRESSION_LIMITS.maxHumanizePercent,
|
|
201
|
+
Math.max(0, value + direction),
|
|
202
|
+
),
|
|
203
|
+
format: (value) => `±${round(value)}%`,
|
|
204
|
+
command: (value) => humanizeCommand(timing, value, length),
|
|
205
|
+
off: "off",
|
|
206
|
+
start: DEFAULT_HUMANIZE.velocity,
|
|
207
|
+
reset: "humanize off",
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
kind: "number",
|
|
211
|
+
label: "humanize length (%)",
|
|
212
|
+
value: humanize ? length : undefined,
|
|
213
|
+
min: 0,
|
|
214
|
+
max: EXPRESSION_LIMITS.maxHumanizePercent,
|
|
215
|
+
step: (value, direction) =>
|
|
216
|
+
Math.min(
|
|
217
|
+
EXPRESSION_LIMITS.maxHumanizePercent,
|
|
218
|
+
Math.max(0, value + direction),
|
|
219
|
+
),
|
|
220
|
+
format: (value) => `±${round(value)}%`,
|
|
221
|
+
command: (value) => humanizeCommand(timing, velocity, value),
|
|
222
|
+
off: "off",
|
|
223
|
+
start: 5,
|
|
224
|
+
reset: "humanize off",
|
|
225
|
+
},
|
|
226
|
+
);
|
|
227
|
+
if (humanize)
|
|
228
|
+
nodes.push({
|
|
229
|
+
kind: "action",
|
|
230
|
+
label: `new take (seed ${humanize.seed})`,
|
|
231
|
+
command: "humanize reseed",
|
|
232
|
+
help: "the same amounts with a different seeded performance",
|
|
233
|
+
});
|
|
234
|
+
return nodes;
|
|
235
|
+
}
|
package/src/tui/play-chords.ts
CHANGED
|
@@ -22,9 +22,15 @@
|
|
|
22
22
|
*
|
|
23
23
|
* Pure: the session asks for a chord per note key and owns sound and time.
|
|
24
24
|
*/
|
|
25
|
+
import { resolveTuning, snapToTuning } from "../../core/tuning.ts";
|
|
25
26
|
import {
|
|
26
27
|
BASS_MODES,
|
|
27
28
|
CHORD_PATTERNS,
|
|
29
|
+
DEFAULT_ARP_RATE,
|
|
30
|
+
DEFAULT_STRUM,
|
|
31
|
+
generateProgression,
|
|
32
|
+
presetChords,
|
|
33
|
+
renderProgression,
|
|
28
34
|
CHORD_TYPES,
|
|
29
35
|
EXTENSIONS,
|
|
30
36
|
MAX_VOICING_STEP,
|
|
@@ -56,7 +62,12 @@ import {
|
|
|
56
62
|
type ProgressionStyle,
|
|
57
63
|
type Spread,
|
|
58
64
|
} from "../../core/chords.ts";
|
|
59
|
-
import {
|
|
65
|
+
import {
|
|
66
|
+
isSamplerInstrument,
|
|
67
|
+
type NoteInput,
|
|
68
|
+
type Track,
|
|
69
|
+
type TrackScore,
|
|
70
|
+
} from "../../core/score.ts";
|
|
60
71
|
|
|
61
72
|
export const CHORD_MODES = ["auto", "manual", "off"] as const;
|
|
62
73
|
export type ChordMode = (typeof CHORD_MODES)[number];
|
|
@@ -536,3 +547,86 @@ export function chordsSummary(settings: ChordSettings): string {
|
|
|
536
547
|
`style ${s.style}`,
|
|
537
548
|
].join(" · ");
|
|
538
549
|
}
|
|
550
|
+
|
|
551
|
+
/** Chords in the chord settings screen's audition phrase. */
|
|
552
|
+
const PHRASE_CHORDS = 4;
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* What the chord settings sound like, for the audition loop: the chosen
|
|
556
|
+
* progression (the preset, or four chords of the style's seeded walk) in the
|
|
557
|
+
* song key, voiced and performed with `settings` (voicing, spread, sevenths,
|
|
558
|
+
* perform mode, pattern, arp rate and octaves, bass mode), one chord per bar
|
|
559
|
+
* or two per bar in a one-bar loop. With chords `off` it plays each chord's
|
|
560
|
+
* root alone, as play mode would. Deterministic: the same settings and key
|
|
561
|
+
* always give the same notes.
|
|
562
|
+
*/
|
|
563
|
+
export function chordPhrase(
|
|
564
|
+
settings: ChordSettings,
|
|
565
|
+
score: TrackScore,
|
|
566
|
+
track: Track,
|
|
567
|
+
bars: number,
|
|
568
|
+
): NoteInput[] {
|
|
569
|
+
const key = parseKey(score.key) ?? { tonic: 0, mode: "major" as const };
|
|
570
|
+
const preset =
|
|
571
|
+
settings.preset === "none" ? undefined : findPreset(settings.preset);
|
|
572
|
+
const progression = preset
|
|
573
|
+
? presetChords(key, preset)
|
|
574
|
+
: generateProgression({
|
|
575
|
+
key,
|
|
576
|
+
length: PHRASE_CHORDS,
|
|
577
|
+
style: settings.style,
|
|
578
|
+
seed: 1,
|
|
579
|
+
sevenths: settings.sevenths,
|
|
580
|
+
});
|
|
581
|
+
const tpb = score.ticksPerBeat;
|
|
582
|
+
const totalBeats = Math.max(1, bars) * score.beatsPerBar;
|
|
583
|
+
const count = Math.max(2, Math.max(1, bars));
|
|
584
|
+
const span = totalBeats / count;
|
|
585
|
+
const chords = Array.from(
|
|
586
|
+
{ length: count },
|
|
587
|
+
(_, index) => progression[index % progression.length]!,
|
|
588
|
+
);
|
|
589
|
+
const mode = settings.perform;
|
|
590
|
+
const rendered = renderProgression({
|
|
591
|
+
key,
|
|
592
|
+
chords,
|
|
593
|
+
beatsPerChord: span,
|
|
594
|
+
inversion: settings.inversion,
|
|
595
|
+
spread: settings.spread,
|
|
596
|
+
bassMode: settings.mode === "off" ? "solo" : settings.bass,
|
|
597
|
+
perform: {
|
|
598
|
+
mode,
|
|
599
|
+
rate:
|
|
600
|
+
settings.rate === "grid" ? DEFAULT_ARP_RATE : RATE_BEATS[settings.rate],
|
|
601
|
+
octaves: settings.octaves,
|
|
602
|
+
strum: mode === "harp" ? DEFAULT_STRUM * 2 : DEFAULT_STRUM,
|
|
603
|
+
seed: 1,
|
|
604
|
+
velocity: 0.8,
|
|
605
|
+
...(mode === "pattern" ? { pattern: settings.pattern } : {}),
|
|
606
|
+
},
|
|
607
|
+
});
|
|
608
|
+
// With chords off a key plays one note: the root, in the chord's octave.
|
|
609
|
+
const performed =
|
|
610
|
+
settings.mode === "off"
|
|
611
|
+
? rendered.bass.map((note) => ({ ...note, pitch: note.pitch + 24 }))
|
|
612
|
+
: [...rendered.notes, ...rendered.bass];
|
|
613
|
+
const end = totalBeats * tpb;
|
|
614
|
+
const table = resolveTuning(score.tuning, track.tuning, score.key);
|
|
615
|
+
const notes: NoteInput[] = [];
|
|
616
|
+
for (const note of performed) {
|
|
617
|
+
const startTick = Math.round(note.start * tpb);
|
|
618
|
+
if (startTick >= end) continue;
|
|
619
|
+
notes.push({
|
|
620
|
+
id: `chords-${notes.length + 1}`,
|
|
621
|
+
trackId: track.id,
|
|
622
|
+
startTick,
|
|
623
|
+
durationTicks: Math.max(
|
|
624
|
+
1,
|
|
625
|
+
Math.min(Math.round(note.length * tpb), end - startTick),
|
|
626
|
+
),
|
|
627
|
+
pitch: snapToTuning(note.pitch, table),
|
|
628
|
+
velocity: note.velocity,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
return notes;
|
|
632
|
+
}
|
package/src/tui/play-mode.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Z / X octave down / up C / V velocity down / up (steps of 16)
|
|
12
12
|
* Shift sustain while held Tab sustain latch Esc leave the mode
|
|
13
|
+
* (while recording, sustain changes record as track pedal events)
|
|
14
|
+
* I scale degrees: the home row plays the key's scale (any tuning)
|
|
13
15
|
*
|
|
14
16
|
* Terminals report key-down only, so a held key is synthesized from the
|
|
15
17
|
* keyboard's auto-repeat: a press sounds for the gate (one grid step by
|
|
@@ -21,12 +23,14 @@
|
|
|
21
23
|
*
|
|
22
24
|
* Everything here is pure: callers pass the monotonic time of each key.
|
|
23
25
|
*/
|
|
26
|
+
import { keyName, parseKey, scaleSteps } from "../../core/chords.ts";
|
|
24
27
|
import {
|
|
25
28
|
SAMPLER_FIRST_SLOT,
|
|
26
29
|
isSamplerInstrument,
|
|
27
30
|
samplerVoiceSlots,
|
|
28
31
|
type Track,
|
|
29
32
|
} from "../../core/score.ts";
|
|
33
|
+
import type { TuningTable } from "../../core/tuning.ts";
|
|
30
34
|
import { pitchName } from "../../tui/highway.ts";
|
|
31
35
|
|
|
32
36
|
/** Semitone offset from the base C for each note key. */
|
|
@@ -148,8 +152,86 @@ export function rangeLabel(base: number): string {
|
|
|
148
152
|
return `${pitchName(base)}–${pitchName(base + LAYOUT_SPAN)}`;
|
|
149
153
|
}
|
|
150
154
|
|
|
155
|
+
/** The home row left to right: consecutive degrees in degree mode. */
|
|
156
|
+
export const HOME_ROW: readonly string[] = Object.freeze(
|
|
157
|
+
Object.keys(WHITE_KEYS),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Scale-degree layout (Push's "In Key", Komplete Kontrol's Easy mode): the
|
|
162
|
+
* home row plays consecutive degrees from the tonic and the upper row is
|
|
163
|
+
* off, so every key is in the scale.
|
|
164
|
+
*/
|
|
165
|
+
export type DegreeLayout = Readonly<{
|
|
166
|
+
/** Key offsets of the degrees within one period, ascending from 0. */
|
|
167
|
+
steps: readonly number[];
|
|
168
|
+
/** Keys per period: 12, or a linear tuning's step count. */
|
|
169
|
+
period: number;
|
|
170
|
+
/** Key of degree 0 with the keyboard at its default base (C3). */
|
|
171
|
+
tonic: number;
|
|
172
|
+
/** `D dorian`, `19-edo`, `C major · 19-edo`. */
|
|
173
|
+
name: string;
|
|
174
|
+
}>;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The degrees the home row plays for a song key and the track's tuning. A
|
|
178
|
+
* twelve-key tuning (12-TET, just intonation, a maqam preset) plays the
|
|
179
|
+
* key's scale on the twelve keys, quarter tones on the key their preset
|
|
180
|
+
* retunes; a linear non-12 tuning (19-EDO, pelog) plays every step, or the
|
|
181
|
+
* nearest step to each scale note when a key is set. No key reads C major.
|
|
182
|
+
*/
|
|
183
|
+
export function degreeLayout(
|
|
184
|
+
keyText: string | null | undefined,
|
|
185
|
+
table: TuningTable | undefined,
|
|
186
|
+
): DegreeLayout {
|
|
187
|
+
const key = parseKey(keyText ?? undefined);
|
|
188
|
+
if (table && table.linear && table.size !== 12) {
|
|
189
|
+
const period = table.size;
|
|
190
|
+
const pc = ((table.root % 12) + 12) % 12;
|
|
191
|
+
const tonic =
|
|
192
|
+
table.root + Math.round((DEFAULT_BASE + pc - table.root) / 12) * period;
|
|
193
|
+
const at = (offset: number) =>
|
|
194
|
+
table.hz[Math.max(0, Math.min(127, tonic + offset))]!;
|
|
195
|
+
const degreeCents = Array.from({ length: period }, (_, offset) =>
|
|
196
|
+
at(offset) > 0 && at(0) > 0 ? 1200 * Math.log2(at(offset) / at(0)) : NaN,
|
|
197
|
+
);
|
|
198
|
+
const steps = key
|
|
199
|
+
? [
|
|
200
|
+
...new Set(
|
|
201
|
+
scaleSteps(key).map((step) => {
|
|
202
|
+
let best = 0;
|
|
203
|
+
for (let offset = 1; offset < period; offset += 1)
|
|
204
|
+
if (
|
|
205
|
+
Math.abs(degreeCents[offset]! - step * 100) <
|
|
206
|
+
Math.abs(degreeCents[best]! - step * 100)
|
|
207
|
+
)
|
|
208
|
+
best = offset;
|
|
209
|
+
return best;
|
|
210
|
+
}),
|
|
211
|
+
),
|
|
212
|
+
].sort((a, b) => a - b)
|
|
213
|
+
: degreeCents.map((_, offset) => offset);
|
|
214
|
+
return Object.freeze({
|
|
215
|
+
steps: Object.freeze(steps),
|
|
216
|
+
period,
|
|
217
|
+
tonic,
|
|
218
|
+
name: key ? `${keyName(key)} · ${table.name}` : table.name,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
const chosen = key ?? { tonic: 0, mode: "major" as const };
|
|
222
|
+
const steps = [
|
|
223
|
+
...new Set(scaleSteps(chosen).map((step) => Math.ceil(step) % 12)),
|
|
224
|
+
].sort((a, b) => a - b);
|
|
225
|
+
return Object.freeze({
|
|
226
|
+
steps: Object.freeze(steps),
|
|
227
|
+
period: 12,
|
|
228
|
+
tonic: DEFAULT_BASE + chosen.tonic,
|
|
229
|
+
name: keyName(chosen),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
151
233
|
export type PlayCommand =
|
|
152
|
-
"exit" | "record" | "replace" | "click" | "transport" | "menu";
|
|
234
|
+
"exit" | "record" | "replace" | "click" | "transport" | "menu" | "degrees";
|
|
153
235
|
|
|
154
236
|
/** Keys that drive the mode itself rather than notes. */
|
|
155
237
|
const COMMAND_KEYS: Readonly<Record<string, PlayCommand>> = Object.freeze({
|
|
@@ -158,6 +240,7 @@ const COMMAND_KEYS: Readonly<Record<string, PlayCommand>> = Object.freeze({
|
|
|
158
240
|
R: "replace",
|
|
159
241
|
m: "click",
|
|
160
242
|
M: "click",
|
|
243
|
+
i: "degrees",
|
|
161
244
|
" ": "transport",
|
|
162
245
|
"\u000b": "menu",
|
|
163
246
|
});
|
|
@@ -225,6 +308,27 @@ export class PlayKeyboard {
|
|
|
225
308
|
private nextId = 1;
|
|
226
309
|
/** Shift was held on the last note key (BandLab's Sustain light). */
|
|
227
310
|
public shiftHeld = false;
|
|
311
|
+
private layout: DegreeLayout | undefined;
|
|
312
|
+
/**
|
|
313
|
+
* Degrees the home row is shifted by when a period has more steps than
|
|
314
|
+
* the home row has keys (19- or 31-EDO): Z and X page by a row instead of
|
|
315
|
+
* a period, so every step stays reachable.
|
|
316
|
+
*/
|
|
317
|
+
public degreeOffset = 0;
|
|
318
|
+
|
|
319
|
+
/** Scale-degree layout (`i` in play mode); undefined is chromatic. */
|
|
320
|
+
public get degrees(): DegreeLayout | undefined {
|
|
321
|
+
return this.layout;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
public set degrees(layout: DegreeLayout | undefined) {
|
|
325
|
+
if (
|
|
326
|
+
layout?.steps.length !== this.layout?.steps.length ||
|
|
327
|
+
layout?.period !== this.layout?.period
|
|
328
|
+
)
|
|
329
|
+
this.degreeOffset = 0;
|
|
330
|
+
this.layout = layout;
|
|
331
|
+
}
|
|
228
332
|
|
|
229
333
|
public constructor(options: PlayKeyboardOptions = {}) {
|
|
230
334
|
this.base = clampBase(options.base ?? DEFAULT_BASE);
|
|
@@ -236,12 +340,30 @@ export class PlayKeyboard {
|
|
|
236
340
|
}
|
|
237
341
|
|
|
238
342
|
public get range(): string {
|
|
239
|
-
return rangeLabel(this.base);
|
|
343
|
+
if (!this.degrees) return rangeLabel(this.base);
|
|
344
|
+
const low = this.pitchFor(HOME_ROW[0]!);
|
|
345
|
+
const high = this.pitchFor(HOME_ROW[HOME_ROW.length - 1]!);
|
|
346
|
+
return low === undefined || high === undefined
|
|
347
|
+
? "out of range"
|
|
348
|
+
: `${pitchName(low)}–${pitchName(high)}`;
|
|
240
349
|
}
|
|
241
350
|
|
|
242
351
|
/** Pitch a note key plays at the current octave, or undefined. */
|
|
243
352
|
public pitchFor(key: string): number | undefined {
|
|
244
|
-
const
|
|
353
|
+
const name = SHIFTED[key] ?? key.toLowerCase();
|
|
354
|
+
if (this.degrees) {
|
|
355
|
+
const index = HOME_ROW.indexOf(name);
|
|
356
|
+
if (index < 0) return undefined;
|
|
357
|
+
const { steps, period, tonic } = this.degrees;
|
|
358
|
+
const degree = this.degreeOffset + index;
|
|
359
|
+
const octave = Math.floor(degree / steps.length);
|
|
360
|
+
const pitch =
|
|
361
|
+
tonic +
|
|
362
|
+
((this.base - DEFAULT_BASE) / 12 + octave) * period +
|
|
363
|
+
steps[degree - octave * steps.length]!;
|
|
364
|
+
return pitch >= MIN_PITCH && pitch <= MAX_PITCH ? pitch : undefined;
|
|
365
|
+
}
|
|
366
|
+
const offset = NOTE_KEYS[name];
|
|
245
367
|
if (offset === undefined) return undefined;
|
|
246
368
|
return this.base + offset;
|
|
247
369
|
}
|
|
@@ -271,8 +393,25 @@ export class PlayKeyboard {
|
|
|
271
393
|
if (command) return { type: "command", command };
|
|
272
394
|
const lower = value.length === 1 ? value.toLowerCase() : value;
|
|
273
395
|
if (lower === "z" || lower === "x") {
|
|
396
|
+
if (this.degrees && this.degrees.steps.length > HOME_ROW.length) {
|
|
397
|
+
// Page by a home row of degrees; stop before the row falls silent.
|
|
398
|
+
const previous = this.degreeOffset;
|
|
399
|
+
this.degreeOffset += lower === "z" ? -HOME_ROW.length : HOME_ROW.length;
|
|
400
|
+
const clamped = HOME_ROW.every(
|
|
401
|
+
(key) => this.pitchFor(key) === undefined,
|
|
402
|
+
);
|
|
403
|
+
if (clamped) this.degreeOffset = previous;
|
|
404
|
+
return { type: "octave", base: this.base, clamped };
|
|
405
|
+
}
|
|
274
406
|
const wanted = this.base + (lower === "z" ? -12 : 12);
|
|
407
|
+
const previous = this.base;
|
|
275
408
|
this.base = clampBase(wanted);
|
|
409
|
+
// Degree mode moves a period; stop before the home row falls silent.
|
|
410
|
+
if (
|
|
411
|
+
this.degrees &&
|
|
412
|
+
HOME_ROW.every((key) => this.pitchFor(key) === undefined)
|
|
413
|
+
)
|
|
414
|
+
this.base = previous;
|
|
276
415
|
return { type: "octave", base: this.base, clamped: wanted !== this.base };
|
|
277
416
|
}
|
|
278
417
|
if (lower === "c" || lower === "v") {
|
|
@@ -428,10 +567,15 @@ export function stripCells(
|
|
|
428
567
|
lit: ReadonlySet<string>,
|
|
429
568
|
labels: ReadonlyMap<number, string> = new Map(),
|
|
430
569
|
): StripCell[] {
|
|
431
|
-
|
|
432
|
-
|
|
570
|
+
const order = keyboard.degrees ? HOME_ROW : STRIP_ORDER;
|
|
571
|
+
return order.map((key) => {
|
|
572
|
+
const pitch = keyboard.pitchFor(key);
|
|
433
573
|
const name =
|
|
434
|
-
|
|
574
|
+
pitch === undefined
|
|
575
|
+
? "·"
|
|
576
|
+
: labels.size > 0
|
|
577
|
+
? (labels.get(pitch) ?? "·")
|
|
578
|
+
: pitchName(pitch);
|
|
435
579
|
return {
|
|
436
580
|
key,
|
|
437
581
|
label: name,
|