@hraness/dawg 0.6.0 → 0.7.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 +126 -0
- package/DAWG.md +655 -141
- package/README.md +27 -25
- package/core/autotune.ts +1119 -0
- package/core/chords.ts +759 -23
- package/core/clips.ts +499 -0
- package/core/diff.ts +182 -99
- package/core/expression.ts +158 -6
- package/core/fx.ts +354 -32
- package/core/granular.ts +91 -0
- package/core/instruments.ts +180 -0
- package/core/keys.ts +573 -4
- package/core/loop.ts +5 -0
- package/core/lyrics.ts +297 -0
- package/core/master.ts +3 -3
- package/core/resonators.ts +306 -4
- package/core/routing.ts +165 -0
- package/core/score.ts +1181 -16
- package/core/sdk/eval-child.ts +7 -2
- package/core/sdk/eval.ts +35 -6
- package/core/sdk/print.ts +354 -6
- package/core/sdk/sync-lyrics.ts +49 -0
- package/core/sdk/v1.ts +3160 -69
- package/core/sections.ts +298 -24
- package/core/sing.ts +815 -0
- package/core/strings.ts +237 -2
- package/core/style-provenance.ts +80 -0
- package/core/styles/africa-mena-southasia.ts +2893 -0
- package/core/styles/americas.ts +3810 -0
- package/core/styles/art.ts +4993 -0
- package/core/styles/base.ts +123 -0
- package/core/styles/cycles.ts +106 -0
- package/core/styles/electronic.ts +2723 -0
- package/core/styles/europe-asia-pacific.ts +2838 -0
- package/core/styles/excerpt.ts +29 -0
- package/core/styles/gamelan.ts +283 -0
- package/core/styles/generate.ts +2199 -0
- package/core/styles/index.ts +515 -0
- package/core/styles/parts.ts +106 -0
- package/core/styles/pop.ts +3189 -0
- package/core/styles/rock.ts +2993 -0
- package/core/styles/roots.ts +4175 -0
- package/core/styles/schema.ts +429 -0
- package/core/styles/taxonomy.ts +940 -0
- package/core/styles/validate.ts +528 -0
- package/core/tempo.ts +32 -2
- package/core/tuning.ts +19 -3
- package/core/vocoder.ts +524 -0
- package/core/winds.ts +652 -0
- package/guides/agent.md +29 -0
- package/guides/arrange.md +30 -0
- package/guides/audition.md +20 -14
- package/guides/automation.md +12 -6
- package/guides/chords.md +16 -14
- package/guides/effects.md +17 -15
- package/guides/faders.md +20 -16
- package/guides/files.md +13 -9
- package/guides/getting-started.md +15 -11
- package/guides/keys.md +19 -15
- package/guides/media.md +17 -12
- package/guides/mix.md +12 -6
- package/guides/music.md +23 -8
- package/guides/notes.md +15 -9
- package/guides/performance.md +15 -10
- package/guides/play.md +21 -13
- package/guides/project.md +25 -8
- package/guides/providers.md +20 -14
- package/guides/resample.md +32 -0
- package/guides/rhythm.md +17 -13
- package/guides/sessions.md +17 -7
- package/guides/show-me.md +31 -0
- package/guides/sound.md +25 -9
- package/guides/sounds.md +15 -12
- package/guides/styles.md +31 -0
- package/guides/tempo.md +15 -10
- package/guides/tracks.md +15 -10
- package/guides/tuning.md +32 -0
- package/guides/voice.md +31 -0
- package/guides/web-search.md +18 -8
- package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
- package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
- package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
- package/native/prebuilt/manifest.json +21 -0
- package/package.json +5 -2
- package/src/agent/agent.ts +131 -13
- package/src/agent/calibration-tools.ts +53 -0
- package/src/agent/chord-tools.ts +153 -1
- package/src/agent/clip-tools.ts +453 -0
- package/src/agent/command-agent.ts +369 -0
- package/src/agent/drum-tools.ts +2 -2
- package/src/agent/expression-tools.ts +91 -0
- package/src/agent/gateway.ts +246 -60
- package/src/agent/models.ts +53 -12
- package/src/agent/ops.ts +15 -1
- package/src/agent/pack-tools.ts +1 -1
- package/src/agent/planner.ts +13 -0
- package/src/agent/portable-schema.ts +80 -0
- package/src/agent/preview-tool.ts +5 -1
- package/src/agent/provider.ts +22 -8
- package/src/agent/resample-tool.ts +131 -0
- package/src/agent/rhythm-tools.ts +1 -1
- package/src/agent/section-tools.ts +1 -1
- package/src/agent/show-me.ts +497 -0
- package/src/agent/steer.ts +15 -0
- package/src/agent/style-tools.ts +217 -0
- package/src/agent/tool-error.ts +12 -0
- package/src/agent/tools.ts +307 -37
- package/src/agent/usage.ts +2 -2
- package/src/agent/voice-tools.ts +925 -0
- package/src/agent/xcb-agent.ts +16 -6
- package/src/argv.ts +38 -0
- package/src/audio/analysis.ts +253 -0
- package/src/audio/arrange.ts +58 -3
- package/src/audio/autotune-engine.ts +101 -0
- package/src/audio/autotune.ts +640 -0
- package/src/audio/clips.ts +240 -0
- package/src/audio/doctor.ts +86 -0
- package/src/audio/dsp/bandbank.ts +138 -0
- package/src/audio/dsp/envelope.ts +171 -0
- package/src/audio/dsp/follow.ts +120 -0
- package/src/audio/dsp/formant.ts +427 -0
- package/src/audio/dsp/glottal.ts +243 -0
- package/src/audio/dsp/interp.ts +7 -2
- package/src/audio/dsp/lpc.ts +50 -0
- package/src/audio/dsp/periodicity.ts +59 -0
- package/src/audio/dsp/pitch.ts +995 -0
- package/src/audio/dsp/psola.ts +199 -0
- package/src/audio/dsp/shift.ts +256 -0
- package/src/audio/effects/chain.ts +21 -4
- package/src/audio/effects/common.ts +57 -0
- package/src/audio/effects/convolution.ts +113 -5
- package/src/audio/effects/filter.ts +48 -69
- package/src/audio/effects/formant.ts +263 -0
- package/src/audio/effects/gaze.ts +354 -0
- package/src/audio/engine.ts +206 -33
- package/src/audio/fit.ts +80 -3
- package/src/audio/granular.ts +352 -21
- package/src/audio/instrument-check.ts +67 -47
- package/src/audio/instruments.ts +36 -3
- package/src/audio/keys/calibration.ts +56 -0
- package/src/audio/keys/electric.ts +427 -0
- package/src/audio/keys/engine.ts +156 -20
- package/src/audio/keys/organ.ts +1335 -0
- package/src/audio/keys/piano.ts +72 -8
- package/src/audio/keys/sympathetic.ts +127 -0
- package/src/audio/kits.ts +135 -6
- package/src/audio/live-worker.ts +3 -1
- package/src/audio/live.ts +247 -26
- package/src/audio/native.ts +615 -0
- package/src/audio/preview.ts +45 -3
- package/src/audio/render-worker.ts +2 -0
- package/src/audio/renderer.ts +2 -0
- package/src/audio/resample.ts +286 -0
- package/src/audio/resonators.ts +10 -2
- package/src/audio/sampler.ts +252 -24
- package/src/audio/samples.ts +46 -3
- package/src/audio/sing/analysis.ts +193 -0
- package/src/audio/sing/engine.ts +949 -0
- package/src/audio/strings/body.ts +29 -16
- package/src/audio/strings/bow.ts +699 -0
- package/src/audio/strings/engine.ts +274 -22
- package/src/audio/strings/measure.test-helpers.ts +2 -0
- package/src/audio/synth/oscillators.ts +31 -21
- package/src/audio/synth/voice.ts +34 -1
- package/src/audio/vocoder/bank.ts +314 -0
- package/src/audio/vocoder/carrier.ts +165 -0
- package/src/audio/vocoder/control.ts +68 -0
- package/src/audio/vocoder/detect.ts +50 -0
- package/src/audio/vocoder/index.ts +304 -0
- package/src/audio/vocoder/talkbox.ts +143 -0
- package/src/audio/wav.ts +608 -85
- package/src/audio/winds/engine.ts +306 -0
- package/src/audio/winds/filters.ts +153 -0
- package/src/audio/winds/pitch.ts +104 -0
- package/src/audio/winds/trim.ts +104 -0
- package/src/audio/winds/trims.ts +917 -0
- package/src/audio/winds/trims1.ts +297 -0
- package/src/audio/winds/voice.ts +492 -0
- package/src/auth/cli.ts +38 -36
- package/src/auth/credentials.ts +30 -1
- package/src/auth/login.ts +15 -9
- package/src/auth/tui.ts +19 -10
- package/src/commands/arrange.ts +44 -29
- package/src/commands/autotune.ts +421 -0
- package/src/commands/calibration.ts +74 -0
- package/src/commands/clips.ts +887 -0
- package/src/commands/drums.ts +3 -2
- package/src/commands/edit.ts +11 -4
- package/src/commands/expression.ts +104 -23
- package/src/commands/formant.ts +221 -0
- package/src/commands/fx.ts +105 -33
- package/src/commands/grammar.ts +558 -0
- package/src/commands/granular.ts +7 -2
- package/src/commands/help.ts +652 -339
- package/src/commands/history.ts +18 -0
- package/src/commands/keys.ts +293 -17
- package/src/commands/modal.ts +67 -4
- package/src/commands/music.ts +1 -1
- package/src/commands/nearest.ts +53 -0
- package/src/commands/pack.ts +9 -2
- package/src/commands/param-range.ts +56 -0
- package/src/commands/parses.ts +130 -0
- package/src/commands/progression.ts +170 -0
- package/src/commands/resample.ts +281 -0
- package/src/commands/rhythm.ts +3 -0
- package/src/commands/rig.ts +8 -25
- package/src/commands/sample.ts +49 -2
- package/src/commands/shift.ts +119 -0
- package/src/commands/sing.ts +478 -0
- package/src/commands/string.ts +28 -2
- package/src/commands/strum.ts +485 -0
- package/src/commands/style.ts +415 -0
- package/src/commands/time.ts +6 -3
- package/src/commands/tuning.ts +3 -3
- package/src/commands/vocal-pitch.ts +616 -0
- package/src/commands/vocal.ts +147 -0
- package/src/commands/vocoder.ts +627 -0
- package/src/commands/wind.ts +244 -0
- package/src/fs/durable.ts +50 -0
- package/src/lang/glossary.ts +493 -0
- package/src/launch-args.ts +163 -0
- package/src/main.ts +1521 -255
- package/src/media/cli.ts +20 -3
- package/src/media/import.ts +3 -1
- package/src/project/check.ts +26 -3
- package/src/project/clip-pins.ts +72 -0
- package/src/project/init.ts +23 -8
- package/src/project/sync.ts +418 -86
- package/src/render.ts +36 -1
- package/src/session/daemon.ts +3 -0
- package/src/session/meta.ts +14 -0
- package/src/session/origin.ts +154 -0
- package/src/session/port.ts +22 -4
- package/src/session/presence.ts +50 -7
- package/src/session/protocol.ts +5 -1
- package/src/session/rebase.ts +18 -5
- package/src/session/receipt.ts +258 -0
- package/src/session/store.ts +65 -43
- package/src/tui/arrange-menu.ts +65 -31
- package/src/tui/audition.ts +1 -1
- package/src/tui/euclid.ts +18 -13
- package/src/tui/fader.ts +228 -41
- package/src/tui/granular-menu.ts +2 -4
- package/src/tui/menu-clips.ts +297 -0
- package/src/tui/menu-time.ts +20 -13
- package/src/tui/menu-voice.ts +405 -0
- package/src/tui/menu.ts +1377 -193
- package/src/tui/modal-menu.ts +38 -11
- package/src/tui/performance-menu.ts +46 -1
- package/src/tui/play-chords.ts +108 -5
- package/src/tui/play-mode.ts +16 -1
- package/src/tui/play-session.ts +81 -16
- package/src/tui/sing-menu.ts +278 -0
- package/src/tui/style-menu.ts +104 -0
- package/src/tui/vocoder-menu.ts +244 -0
- package/src/tui/wind-menu.ts +144 -0
- package/src/version.ts +8 -0
- package/src/web/fetch.ts +115 -29
- package/tui/activity.ts +180 -9
- package/tui/app.ts +274 -40
- package/tui/clip-row.ts +132 -0
- package/tui/delight.ts +144 -0
- package/tui/drawer.ts +70 -22
- package/tui/frame-gate.ts +76 -0
- package/tui/grammar.ts +112 -63
- package/tui/guide.ts +42 -4
- package/tui/highway.ts +269 -25
- package/tui/hints.ts +192 -0
- package/tui/input.ts +60 -9
- package/tui/keys.ts +1 -1
- package/tui/play-strip.ts +68 -14
- package/tui/prompt.ts +1 -1
- package/tui/screen.ts +144 -14
- package/tui/theme.ts +27 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Musical receipts: one line that says what changed in the song, built from
|
|
3
|
+
* the score operations a change applied (`diffScores(before, after)`).
|
|
4
|
+
*
|
|
5
|
+
* ◆ 96 BPM · +2 notes on bass (C4 E4) · reverb 0.4
|
|
6
|
+
*
|
|
7
|
+
* Receipts name tempo in BPM, notes by pitch, and track settings by their
|
|
8
|
+
* musical value; revision numbers and validator counts stay in the ctrl-o
|
|
9
|
+
* log. The builder is pure, so the agent turn, undo and redo share it.
|
|
10
|
+
*/
|
|
11
|
+
import { diffScores } from "../../core/diff.ts";
|
|
12
|
+
import { drumVoiceForPitch, isDrumInstrument } from "../../core/drums.ts";
|
|
13
|
+
import { midiToPitch } from "../../core/pitch.ts";
|
|
14
|
+
import type { ScoreOperation, TrackScore } from "../../core/score.ts";
|
|
15
|
+
|
|
16
|
+
/** Parts kept before the rest collapse to `+N more`. */
|
|
17
|
+
const MAX_PARTS = 5;
|
|
18
|
+
/** Pitch names listed after a note count. */
|
|
19
|
+
const MAX_PITCHES = 4;
|
|
20
|
+
|
|
21
|
+
function plural(count: number, word: string): string {
|
|
22
|
+
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function round(value: number, places = 2): string {
|
|
26
|
+
const scale = 10 ** places;
|
|
27
|
+
return String(Math.round(value * scale) / scale);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function noteName(pitch: number, drum: boolean): string {
|
|
31
|
+
return drum ? drumVoiceForPitch(pitch) : midiToPitch(pitch);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type NoteTally = { added: number[]; removed: number[]; changed: number };
|
|
35
|
+
|
|
36
|
+
/** One field of a track patch as a musical phrase, or undefined to skip. */
|
|
37
|
+
function trackField(field: string, value: unknown): string | undefined {
|
|
38
|
+
if (value === null || value === undefined) {
|
|
39
|
+
if (field === "volumeAutomation" || field.endsWith("Automation"))
|
|
40
|
+
return `${field.replace(/Automation$/, "")} automation off`;
|
|
41
|
+
return `${field} off`;
|
|
42
|
+
}
|
|
43
|
+
switch (field) {
|
|
44
|
+
case "volume":
|
|
45
|
+
return typeof value === "number" ? `volume ${round(value)}` : undefined;
|
|
46
|
+
case "pan":
|
|
47
|
+
return typeof value === "number" ? `pan ${round(value)}` : undefined;
|
|
48
|
+
case "muted":
|
|
49
|
+
return value === true ? "muted" : "unmuted";
|
|
50
|
+
case "solo":
|
|
51
|
+
return value === true ? "solo" : "solo off";
|
|
52
|
+
case "instrument":
|
|
53
|
+
return typeof value === "string" ? `→ ${value}` : undefined;
|
|
54
|
+
case "name":
|
|
55
|
+
return typeof value === "string" ? `renamed ${value}` : undefined;
|
|
56
|
+
case "reverb":
|
|
57
|
+
case "delay": {
|
|
58
|
+
const mix = (value as { mix?: unknown }).mix;
|
|
59
|
+
return typeof mix === "number" ? `${field} ${round(mix)}` : field;
|
|
60
|
+
}
|
|
61
|
+
case "filter": {
|
|
62
|
+
const cutoff = (value as { cutoff?: unknown }).cutoff;
|
|
63
|
+
return typeof cutoff === "number"
|
|
64
|
+
? `filter ${Math.round(cutoff)} Hz`
|
|
65
|
+
: "filter";
|
|
66
|
+
}
|
|
67
|
+
case "fx": {
|
|
68
|
+
const names = Object.keys(value as Record<string, unknown>);
|
|
69
|
+
return names.length ? `fx ${names.join(" ")}` : "fx off";
|
|
70
|
+
}
|
|
71
|
+
default:
|
|
72
|
+
if (Array.isArray(value) && field.endsWith("Automation"))
|
|
73
|
+
return value.length === 0
|
|
74
|
+
? `${field.replace(/Automation$/, "")} automation off`
|
|
75
|
+
: `${field.replace(/Automation$/, "")} automation`;
|
|
76
|
+
return field;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The receipt parts for `operations` applied to `before`. Tracks are named
|
|
82
|
+
* only when the song has more than one, so a single-track edit reads
|
|
83
|
+
* `reverb 0.4` and a band edit `bass reverb 0.4`.
|
|
84
|
+
*/
|
|
85
|
+
export function receiptParts(
|
|
86
|
+
before: TrackScore,
|
|
87
|
+
operations: readonly ScoreOperation[],
|
|
88
|
+
): string[] {
|
|
89
|
+
const parts: string[] = [];
|
|
90
|
+
// Track settings follow the notes: what you hear first reads first.
|
|
91
|
+
const settings: string[] = [];
|
|
92
|
+
const instruments = new Map(
|
|
93
|
+
before.tracks.map((track) => [track.id, track.instrument]),
|
|
94
|
+
);
|
|
95
|
+
const noteTrack = new Map(
|
|
96
|
+
before.notes.map((note) => [note.id, note.trackId]),
|
|
97
|
+
);
|
|
98
|
+
const notePitch = new Map(
|
|
99
|
+
before.notes.map((note) => [
|
|
100
|
+
note.id,
|
|
101
|
+
typeof note.pitch === "number" ? note.pitch : Number.NaN,
|
|
102
|
+
]),
|
|
103
|
+
);
|
|
104
|
+
const tallies = new Map<string, NoteTally>();
|
|
105
|
+
const tally = (trackId: string): NoteTally => {
|
|
106
|
+
let found = tallies.get(trackId);
|
|
107
|
+
if (!found) {
|
|
108
|
+
found = { added: [], removed: [], changed: 0 };
|
|
109
|
+
tallies.set(trackId, found);
|
|
110
|
+
}
|
|
111
|
+
return found;
|
|
112
|
+
};
|
|
113
|
+
const added = new Set<string>();
|
|
114
|
+
const removed = new Set<string>();
|
|
115
|
+
const trackCount = new Set([
|
|
116
|
+
...before.tracks.map((track) => track.id),
|
|
117
|
+
...operations.flatMap((op) =>
|
|
118
|
+
op.type === "addTrack" ? [op.track.id ?? "track"] : [],
|
|
119
|
+
),
|
|
120
|
+
]).size;
|
|
121
|
+
const prefix = (trackId: string) => (trackCount > 1 ? `${trackId} ` : "");
|
|
122
|
+
// Notes are summarised per track after the song-level parts.
|
|
123
|
+
for (const op of operations) {
|
|
124
|
+
switch (op.type) {
|
|
125
|
+
case "setTempo":
|
|
126
|
+
parts.push(`${round(op.tempoBpm, 1)} BPM`);
|
|
127
|
+
break;
|
|
128
|
+
case "setKey":
|
|
129
|
+
parts.push(op.key ? `key ${op.key}` : "no key");
|
|
130
|
+
break;
|
|
131
|
+
case "setBars":
|
|
132
|
+
parts.push(plural(op.bars, "bar"));
|
|
133
|
+
break;
|
|
134
|
+
case "setMeter":
|
|
135
|
+
parts.push(`${op.beatsPerBar} beats a bar`);
|
|
136
|
+
break;
|
|
137
|
+
case "setTime":
|
|
138
|
+
parts.push(op.time ? "tempo map" : "steady tempo");
|
|
139
|
+
break;
|
|
140
|
+
case "setTuning":
|
|
141
|
+
parts.push(
|
|
142
|
+
op.tuning ? `tuning ${op.tuning.name ?? ""}`.trim() : "12-TET",
|
|
143
|
+
);
|
|
144
|
+
break;
|
|
145
|
+
case "setMaster":
|
|
146
|
+
parts.push(op.master ? "master" : "master off");
|
|
147
|
+
break;
|
|
148
|
+
case "setStyle":
|
|
149
|
+
if (op.style) parts.push(op.style.id);
|
|
150
|
+
break;
|
|
151
|
+
case "setSections":
|
|
152
|
+
parts.push(plural(op.sections.length, "section"));
|
|
153
|
+
break;
|
|
154
|
+
case "setCalibration":
|
|
155
|
+
break;
|
|
156
|
+
case "addTrack": {
|
|
157
|
+
const id = op.track.id ?? "track";
|
|
158
|
+
const instrument = op.track.instrument;
|
|
159
|
+
added.add(id);
|
|
160
|
+
instruments.set(id, instrument ?? "");
|
|
161
|
+
parts.push(
|
|
162
|
+
`+${id}${instrument && instrument !== id ? ` (${instrument})` : ""}`,
|
|
163
|
+
);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case "removeTrack":
|
|
167
|
+
removed.add(op.trackId);
|
|
168
|
+
parts.push(`−${op.trackId}`);
|
|
169
|
+
break;
|
|
170
|
+
case "moveTrack":
|
|
171
|
+
parts.push(`moved ${op.trackId}`);
|
|
172
|
+
break;
|
|
173
|
+
case "clearTrack":
|
|
174
|
+
parts.push(`cleared ${op.trackId}`);
|
|
175
|
+
break;
|
|
176
|
+
case "setClips":
|
|
177
|
+
settings.push(
|
|
178
|
+
`${prefix(op.trackId)}${op.clips && op.clips.length ? plural(op.clips.length, "clip") : "no clips"}`,
|
|
179
|
+
);
|
|
180
|
+
break;
|
|
181
|
+
case "setAutomation":
|
|
182
|
+
settings.push(`${prefix(op.trackId)}${op.parameter} automation`);
|
|
183
|
+
break;
|
|
184
|
+
case "updateTrack": {
|
|
185
|
+
if (added.has(op.trackId)) break;
|
|
186
|
+
const fields = Object.entries(op.patch)
|
|
187
|
+
.map(([field, value]) => trackField(field, value))
|
|
188
|
+
.filter((text): text is string => text !== undefined);
|
|
189
|
+
if (fields.length === 0) break;
|
|
190
|
+
const name = op.patch.name;
|
|
191
|
+
const label =
|
|
192
|
+
typeof name === "string" ? op.trackId : prefix(op.trackId).trim();
|
|
193
|
+
const text = fields.slice(0, 2).join(" ");
|
|
194
|
+
settings.push(label ? `${label} ${text}` : text);
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
case "addNote": {
|
|
198
|
+
const entry = tally(op.note.trackId);
|
|
199
|
+
entry.added.push(
|
|
200
|
+
typeof op.note.pitch === "number" ? op.note.pitch : Number.NaN,
|
|
201
|
+
);
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
case "removeNote": {
|
|
205
|
+
const trackId = noteTrack.get(op.noteId);
|
|
206
|
+
if (trackId && !removed.has(trackId))
|
|
207
|
+
tally(trackId).removed.push(notePitch.get(op.noteId) ?? Number.NaN);
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
case "updateNote": {
|
|
211
|
+
const trackId = noteTrack.get(op.noteId);
|
|
212
|
+
if (trackId) tally(trackId).changed += 1;
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
for (const [trackId, entry] of tallies) {
|
|
218
|
+
const drum = isDrumInstrument(instruments.get(trackId));
|
|
219
|
+
const listed = (pitches: readonly number[]): string => {
|
|
220
|
+
const names = pitches
|
|
221
|
+
.filter((pitch) => Number.isFinite(pitch))
|
|
222
|
+
.slice(0, MAX_PITCHES)
|
|
223
|
+
.map((pitch) => noteName(pitch, drum));
|
|
224
|
+
const more = pitches.length > MAX_PITCHES ? " …" : "";
|
|
225
|
+
return names.length ? ` (${names.join(" ")}${more})` : "";
|
|
226
|
+
};
|
|
227
|
+
if (entry.added.length > 0)
|
|
228
|
+
parts.push(
|
|
229
|
+
`+${plural(entry.added.length, "note")} on ${trackId}${listed(entry.added)}`,
|
|
230
|
+
);
|
|
231
|
+
if (entry.removed.length > 0)
|
|
232
|
+
parts.push(
|
|
233
|
+
`−${plural(entry.removed.length, "note")} on ${trackId}${listed(entry.removed)}`,
|
|
234
|
+
);
|
|
235
|
+
if (entry.changed > 0)
|
|
236
|
+
parts.push(`${plural(entry.changed, "note")} edited on ${trackId}`);
|
|
237
|
+
}
|
|
238
|
+
parts.push(...settings);
|
|
239
|
+
if (parts.length > MAX_PARTS)
|
|
240
|
+
return [
|
|
241
|
+
...parts.slice(0, MAX_PARTS - 1),
|
|
242
|
+
`+${parts.length - (MAX_PARTS - 1)} more`,
|
|
243
|
+
];
|
|
244
|
+
return parts;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** `96 BPM · +2 notes on bass (C4 E4) · reverb 0.4`, or "" for no change. */
|
|
248
|
+
export function musicalReceipt(before: TrackScore, after: TrackScore): string {
|
|
249
|
+
if (before === after) return "";
|
|
250
|
+
let operations: readonly ScoreOperation[];
|
|
251
|
+
try {
|
|
252
|
+
operations = diffScores(before, after);
|
|
253
|
+
} catch {
|
|
254
|
+
// A resolution change cannot diff: say the song changed.
|
|
255
|
+
return "song changed";
|
|
256
|
+
}
|
|
257
|
+
return receiptParts(before, operations).join(" · ");
|
|
258
|
+
}
|
package/src/session/store.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { mkdir,
|
|
1
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { durableWrite } from "../fs/durable.ts";
|
|
2
3
|
import { dirname, join } from "node:path";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
4
5
|
import { acquireSessionLock } from "./lock.ts";
|
|
@@ -20,9 +21,13 @@ const MAX_EVENT_BYTES = 64 * 1024;
|
|
|
20
21
|
/** A rewind larger than this (a wholesale import) is not kept; the edit still lands. */
|
|
21
22
|
const MAX_REWIND_BYTES = 1024 * 1024;
|
|
22
23
|
const MAX_EVENTS = 2_000;
|
|
24
|
+
/**
|
|
25
|
+
* When the log is full, this many of the oldest events are folded away so a
|
|
26
|
+
* long session keeps accepting edits. The composition is the checkpoint;
|
|
27
|
+
* undo still reaches back through the events that remain.
|
|
28
|
+
*/
|
|
29
|
+
const FOLD_BATCH = 200;
|
|
23
30
|
export const MAX_RECORD_BYTES = 4 * 1024 * 1024;
|
|
24
|
-
/** Oldest rewinds are dropped in batches of this size when the record fills up. */
|
|
25
|
-
const COMPACT_BATCH = 64;
|
|
26
31
|
const MAX_SESSION_ID_LENGTH = 64;
|
|
27
32
|
const MAX_EVENT_KIND_LENGTH = 128;
|
|
28
33
|
const MAX_TIMESTAMP_LENGTH = 64;
|
|
@@ -56,6 +61,13 @@ export type SessionRecord<T> = {
|
|
|
56
61
|
updatedAt: string;
|
|
57
62
|
composition: T;
|
|
58
63
|
events: SessionEvent[];
|
|
64
|
+
/**
|
|
65
|
+
* Optional (0.7): how many of the oldest events were folded away when the
|
|
66
|
+
* log filled up. `revision === (folded ?? 0) + events.length`, and
|
|
67
|
+
* `events[i].revision === (folded ?? 0) + i + 1`. Absent on records that
|
|
68
|
+
* never filled, so older records load and print unchanged.
|
|
69
|
+
*/
|
|
70
|
+
folded?: number;
|
|
59
71
|
/** Name and lineage. Versioned separately so renames keep the revision. */
|
|
60
72
|
meta: SessionMeta;
|
|
61
73
|
};
|
|
@@ -207,10 +219,9 @@ export async function appendSessionEvent<T>(
|
|
|
207
219
|
try {
|
|
208
220
|
const disk = await readRecord<T>(paths.record);
|
|
209
221
|
if (disk.revision !== current.revision) throw new SessionConflictError();
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
throw new Error(`session event limit ${MAX_EVENTS} reached`);
|
|
222
|
+
// A full log folds its oldest events away instead of refusing the edit.
|
|
223
|
+
const fold = disk.events.length >= MAX_EVENTS ? FOLD_BATCH : 0;
|
|
224
|
+
const folded = (disk.folded ?? 0) + fold;
|
|
214
225
|
const revision = disk.revision + 1;
|
|
215
226
|
const at = new Date().toISOString();
|
|
216
227
|
const appended: SessionEvent = {
|
|
@@ -228,7 +239,8 @@ export async function appendSessionEvent<T>(
|
|
|
228
239
|
revision,
|
|
229
240
|
updatedAt: at,
|
|
230
241
|
composition,
|
|
231
|
-
events: [...disk.events, appended],
|
|
242
|
+
events: [...disk.events.slice(fold), appended],
|
|
243
|
+
...(folded > 0 ? { folded } : {}),
|
|
232
244
|
// Metadata always comes from disk so a rename written by another window
|
|
233
245
|
// between this caller's read and its write is never reverted.
|
|
234
246
|
meta: disk.meta,
|
|
@@ -245,8 +257,10 @@ export async function appendSessionEvent<T>(
|
|
|
245
257
|
/**
|
|
246
258
|
* Keeps the record under its size cap by dropping the oldest events'
|
|
247
259
|
* rewinds, oldest first, so the rewinds that remain are always the newest
|
|
248
|
-
* contiguous run.
|
|
249
|
-
*
|
|
260
|
+
* contiguous run. The appended (last) event's rewind is dropped only when
|
|
261
|
+
* nothing else is left to drop, so undo of the edit just made keeps working
|
|
262
|
+
* whenever its rewind fits on its own. Only the composition and the bounded
|
|
263
|
+
* payloads are irreducible; a record that still does not fit is rejected.
|
|
250
264
|
*/
|
|
251
265
|
function compactRecord<T>(record: SessionRecord<T>): {
|
|
252
266
|
record: SessionRecord<T>;
|
|
@@ -254,23 +268,27 @@ function compactRecord<T>(record: SessionRecord<T>): {
|
|
|
254
268
|
} {
|
|
255
269
|
let current = record;
|
|
256
270
|
let json = JSON.stringify(current);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
current = { ...current, events };
|
|
272
|
-
json = JSON.stringify(current);
|
|
271
|
+
let bytes = Buffer.byteLength(json, "utf8");
|
|
272
|
+
if (bytes <= MAX_RECORD_BYTES) return { record: current, json };
|
|
273
|
+
const events = [...current.events];
|
|
274
|
+
const last = events.length - 1;
|
|
275
|
+
// Measured sizes let the loop strip exactly as many rewinds as needed with
|
|
276
|
+
// one final stringify instead of one per batch.
|
|
277
|
+
// The appended event comes last, after every older rewind is gone.
|
|
278
|
+
for (let index = 0; index <= last && bytes > MAX_RECORD_BYTES; index += 1) {
|
|
279
|
+
const event = events[index]!;
|
|
280
|
+
if (event.rewind === undefined) continue;
|
|
281
|
+
const { rewind, ...rest } = event;
|
|
282
|
+
// `,"rewind":<json>` is what dropping the field saves.
|
|
283
|
+
bytes -= Buffer.byteLength(JSON.stringify(rewind), "utf8") + 10;
|
|
284
|
+
events[index] = rest;
|
|
273
285
|
}
|
|
286
|
+
current = { ...current, events };
|
|
287
|
+
json = JSON.stringify(current);
|
|
288
|
+
if (Buffer.byteLength(json, "utf8") > MAX_RECORD_BYTES)
|
|
289
|
+
throw new SessionValidationError(
|
|
290
|
+
`session record exceeds ${MAX_RECORD_BYTES} bytes`,
|
|
291
|
+
);
|
|
274
292
|
return { record: current, json };
|
|
275
293
|
}
|
|
276
294
|
|
|
@@ -383,8 +401,13 @@ export async function inheritedEvents(
|
|
|
383
401
|
} catch {
|
|
384
402
|
break;
|
|
385
403
|
}
|
|
386
|
-
if (!parent || fork.revision > parent.
|
|
387
|
-
|
|
404
|
+
if (!parent || fork.revision > parent.revision) break;
|
|
405
|
+
// Events folded out of the parent are gone; what remains still ends at
|
|
406
|
+
// the fork point.
|
|
407
|
+
const prefix = parent.events.slice(
|
|
408
|
+
0,
|
|
409
|
+
Math.max(0, fork.revision - (parent.folded ?? 0)),
|
|
410
|
+
);
|
|
388
411
|
chunks.unshift(prefix.slice(-(MAX_INHERITED_EVENTS - total)));
|
|
389
412
|
total += prefix.length;
|
|
390
413
|
fork = parent.meta.forkOf;
|
|
@@ -413,20 +436,10 @@ async function writeAtomic(path: string, contents: string): Promise<void> {
|
|
|
413
436
|
throw new SessionValidationError(
|
|
414
437
|
`session record exceeds ${MAX_RECORD_BYTES} bytes`,
|
|
415
438
|
);
|
|
416
|
-
const temporary = `${path}.${process.pid}.tmp`;
|
|
417
439
|
// Durable before it is visible: the data reaches the disk before the
|
|
418
|
-
// rename publishes it,
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
await handle.writeFile(`${contents}\n`, "utf8");
|
|
422
|
-
await handle.sync();
|
|
423
|
-
} catch (error) {
|
|
424
|
-
await handle.close().catch(() => undefined);
|
|
425
|
-
await rm(temporary, { force: true }).catch(() => undefined);
|
|
426
|
-
throw error;
|
|
427
|
-
}
|
|
428
|
-
await handle.close();
|
|
429
|
-
await rename(temporary, path);
|
|
440
|
+
// rename publishes it, and the directory is synced so the rename survives
|
|
441
|
+
// a power loss too.
|
|
442
|
+
await durableWrite(path, `${contents}\n`, { mkdir: false });
|
|
430
443
|
}
|
|
431
444
|
|
|
432
445
|
async function readRecord<T>(path: string): Promise<SessionRecord<T>> {
|
|
@@ -472,7 +485,15 @@ function validateSessionRecord<T>(value: unknown): SessionRecord<T> {
|
|
|
472
485
|
throw new SessionValidationError(
|
|
473
486
|
`session has more than ${MAX_EVENTS} events`,
|
|
474
487
|
);
|
|
475
|
-
|
|
488
|
+
const folded = record.folded ?? 0;
|
|
489
|
+
if (
|
|
490
|
+
!Number.isSafeInteger(folded) ||
|
|
491
|
+
(folded as number) < 0 ||
|
|
492
|
+
(record.folded !== undefined && folded === 0)
|
|
493
|
+
)
|
|
494
|
+
throw new SessionValidationError("session folded count is invalid");
|
|
495
|
+
const base = folded as number;
|
|
496
|
+
if (record.revision !== base + record.events.length)
|
|
476
497
|
throw new SessionValidationError(
|
|
477
498
|
"session revision does not match its event log",
|
|
478
499
|
);
|
|
@@ -487,7 +508,7 @@ function validateSessionRecord<T>(value: unknown): SessionRecord<T> {
|
|
|
487
508
|
assertSessionId(candidate.id);
|
|
488
509
|
if (
|
|
489
510
|
!Number.isSafeInteger(candidate.revision) ||
|
|
490
|
-
candidate.revision !== index + 1
|
|
511
|
+
candidate.revision !== base + index + 1
|
|
491
512
|
)
|
|
492
513
|
throw new SessionValidationError("session event revisions are invalid");
|
|
493
514
|
if (
|
|
@@ -546,6 +567,7 @@ function validateSessionRecord<T>(value: unknown): SessionRecord<T> {
|
|
|
546
567
|
updatedAt: record.updatedAt,
|
|
547
568
|
composition: record.composition as T,
|
|
548
569
|
events,
|
|
570
|
+
...(base > 0 ? { folded: base } : {}),
|
|
549
571
|
meta,
|
|
550
572
|
};
|
|
551
573
|
}
|
package/src/tui/arrange-menu.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { Section } from "../../core/score.ts";
|
|
|
8
8
|
import { formatForm } from "../../core/sections.ts";
|
|
9
9
|
import { barsLabel } from "../commands/arrange.ts";
|
|
10
10
|
import type { MenuContext, MenuNode } from "./menu.ts";
|
|
11
|
+
import { styleMenuDetail, styleMenuNodes } from "./style-menu.ts";
|
|
11
12
|
|
|
12
13
|
/** `3 sections · form A×2 B · loop chorus`, or how to start. */
|
|
13
14
|
export function arrangeDetail(context: MenuContext): string {
|
|
@@ -20,41 +21,26 @@ export function arrangeDetail(context: MenuContext): string {
|
|
|
20
21
|
return parts.join(" · ");
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Arrange's rows after tracks (§4a): sections (each section, mark bars,
|
|
26
|
+
* add section), then form and the style browser.
|
|
27
|
+
*/
|
|
23
28
|
export function arrangeNodes(context: MenuContext): MenuNode[] {
|
|
24
29
|
const score = context.score;
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
id: `section:${section.name}`,
|
|
28
|
-
label: section.name,
|
|
29
|
-
detail: sectionDetail(context, section),
|
|
30
|
-
help: "loop, jump, mute, vary, move, rename, transitions",
|
|
31
|
-
build: (next) => sectionNodes(next, section.name),
|
|
32
|
-
}));
|
|
33
|
-
nodes.push(
|
|
34
|
-
{
|
|
35
|
-
kind: "entry",
|
|
36
|
-
label: "mark bars",
|
|
37
|
-
value: "",
|
|
38
|
-
placeholder: "name and bars, e.g. verse 1-8",
|
|
39
|
-
example: "section verse 1-8",
|
|
40
|
-
help: "name a bar range; re-marking a name moves its marker",
|
|
41
|
-
command: (text) => {
|
|
42
|
-
const trimmed = text.trim();
|
|
43
|
-
return /\S\s+\d{1,4}(?:[-–]\d{1,4})?$/u.test(trimmed)
|
|
44
|
-
? `section ${trimmed}`
|
|
45
|
-
: undefined;
|
|
46
|
-
},
|
|
47
|
-
},
|
|
30
|
+
const count = score.sections.length;
|
|
31
|
+
const nodes: MenuNode[] = [
|
|
48
32
|
{
|
|
49
|
-
kind: "
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
33
|
+
kind: "menu",
|
|
34
|
+
id: "sections",
|
|
35
|
+
label: "sections",
|
|
36
|
+
detail:
|
|
37
|
+
count === 0
|
|
38
|
+
? "none yet · mark bars"
|
|
39
|
+
: `${count} · ${score.sections.map((section) => section.name).join(", ")}`,
|
|
40
|
+
help: "mark bars, add a section, then loop, jump, mute or vary one",
|
|
41
|
+
build: sectionListNodes,
|
|
56
42
|
},
|
|
57
|
-
|
|
43
|
+
];
|
|
58
44
|
if (score.sections.length > 0) {
|
|
59
45
|
nodes.push({
|
|
60
46
|
kind: "entry",
|
|
@@ -80,6 +66,15 @@ export function arrangeNodes(context: MenuContext): MenuNode[] {
|
|
|
80
66
|
help: "play the whole song (or form) again",
|
|
81
67
|
});
|
|
82
68
|
}
|
|
69
|
+
// The style browser: a whole song from a style (src/tui/style-menu.ts).
|
|
70
|
+
nodes.push({
|
|
71
|
+
kind: "menu",
|
|
72
|
+
id: "style",
|
|
73
|
+
label: "style",
|
|
74
|
+
detail: styleMenuDetail(context),
|
|
75
|
+
help: "make a whole song from a style: the style tree, find and blend",
|
|
76
|
+
build: styleMenuNodes,
|
|
77
|
+
});
|
|
83
78
|
return nodes;
|
|
84
79
|
}
|
|
85
80
|
|
|
@@ -388,3 +383,42 @@ export function fillNodes(section: Section): MenuNode[] {
|
|
|
388
383
|
},
|
|
389
384
|
];
|
|
390
385
|
}
|
|
386
|
+
|
|
387
|
+
/** Arrange › sections: one row per section, then mark bars and add section. */
|
|
388
|
+
function sectionListNodes(context: MenuContext): MenuNode[] {
|
|
389
|
+
const score = context.score;
|
|
390
|
+
const nodes: MenuNode[] = score.sections.map((section) => ({
|
|
391
|
+
kind: "menu",
|
|
392
|
+
id: `section:${section.name}`,
|
|
393
|
+
label: section.name,
|
|
394
|
+
detail: sectionDetail(context, section),
|
|
395
|
+
help: "loop, jump, mute, vary, move, rename, transitions",
|
|
396
|
+
build: (next) => sectionNodes(next, section.name),
|
|
397
|
+
}));
|
|
398
|
+
nodes.push(
|
|
399
|
+
{
|
|
400
|
+
kind: "entry",
|
|
401
|
+
label: "mark bars",
|
|
402
|
+
value: "",
|
|
403
|
+
placeholder: "name and bars, e.g. verse 1-8",
|
|
404
|
+
example: "section verse 1-8",
|
|
405
|
+
help: "name a bar range; re-marking a name moves its marker",
|
|
406
|
+
command: (text) => {
|
|
407
|
+
const trimmed = text.trim();
|
|
408
|
+
return /\S\s+\d{1,4}(?:[-–]\d{1,4})?$/u.test(trimmed)
|
|
409
|
+
? `section ${trimmed}`
|
|
410
|
+
: undefined;
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
kind: "entry",
|
|
415
|
+
label: "add section",
|
|
416
|
+
value: "",
|
|
417
|
+
placeholder: "name and bars, e.g. chorus 8 (empty: next name, 8 bars)",
|
|
418
|
+
example: "section add chorus 8",
|
|
419
|
+
help: "a new section after the last one (extends the song)",
|
|
420
|
+
command: (text) => `section add ${text.trim()}`.trim(),
|
|
421
|
+
},
|
|
422
|
+
);
|
|
423
|
+
return nodes;
|
|
424
|
+
}
|
package/src/tui/audition.ts
CHANGED
|
@@ -528,7 +528,7 @@ export function auditionKey(value: string): AuditionKey | undefined {
|
|
|
528
528
|
* before.
|
|
529
529
|
*/
|
|
530
530
|
const STAGEABLE =
|
|
531
|
-
/^\/?(?:(fx|effects|filter|lowpass|synth|string|modal|wt|wavetable|grain|kit|instrument|vol|volume|pan|gain|speed|warpmode|root|pattern|euclid|art|articulation|bend|vibrato|glide|portamento|velcurve|vel-curve|pedal|sustain|humanize|tuning|tune|master|keys|piano)\s+\S|pack\s+use\s+\S|(?:piano|grand|upright|felt|honkytonk|prepared|ballad)(?:\s+piano)?\s*$)/i;
|
|
531
|
+
/^\/?(?:(clip|fx|effects|filter|lowpass|vocoder(?!\s+new\s*$)|formant|vowel|synth|string|bowed|modal|wind|sing|note\s+vowel|wt|wavetable|grain|kit|instrument|vol|volume|pan|gain|speed|warpmode|root|pattern|euclid|art|articulation|bend|vibrato|glide|portamento|velcurve|vel-curve|pedal|sustain|humanize|tuning|tune|master|keys|piano|epiano|rhodes|wurli|wurlitzer|clav|clavinet)\s+\S|pack\s+use\s+\S|(?:piano|grand|upright|felt|honkytonk|prepared|ballad)(?:\s+piano)?\s*$|(?:epiano|rhodes|suitcase|dyno|wurli|wurlitzer|clav|clavinet|funkclav)\s*$|(?:tonewheel|hammond|b3|gospel|jazzorgan|combo|farfisa|vox|pipe|church|pipeorgan|churchorgan|flutes|cornet|reeds|celeste)(?:\s+\S.*)?$|rotary\s+(?:slow|fast|stop)\s*$)/i;
|
|
532
532
|
/** Subcommands that list or show instead of changing the sound. */
|
|
533
533
|
const READ_ONLY = /^\/?\S+\s+(list|show|info|help|measure|meter|presets?)\s*$/i;
|
|
534
534
|
/** `master <unit>` alone only shows the unit. */
|
package/src/tui/euclid.ts
CHANGED
|
@@ -18,7 +18,18 @@
|
|
|
18
18
|
* `a` flips A/B, `c` solo ↔ in context, Enter keeps the staged changes as
|
|
19
19
|
* one undo step and Esc reverts them.
|
|
20
20
|
*/
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
HINTS,
|
|
23
|
+
KEY_BACKSPACE,
|
|
24
|
+
KEY_BACKTAB,
|
|
25
|
+
KEY_DOWN,
|
|
26
|
+
KEY_ENTER,
|
|
27
|
+
KEY_LEFT,
|
|
28
|
+
KEY_RESET,
|
|
29
|
+
KEY_RIGHT,
|
|
30
|
+
KEY_TAB,
|
|
31
|
+
KEY_UP,
|
|
32
|
+
} from "../../tui/grammar.ts";
|
|
22
33
|
import { DRUM_VOICES, isDrumInstrument } from "../../core/drums.ts";
|
|
23
34
|
import {
|
|
24
35
|
DIVISIONS,
|
|
@@ -284,15 +295,9 @@ export function ringText(row: RhythmRow | undefined): string {
|
|
|
284
295
|
.join("");
|
|
285
296
|
}
|
|
286
297
|
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
const
|
|
290
|
-
const KEY_RIGHT = new Set(["\u001b[C", "\u001bOC", "l", "+", "="]);
|
|
291
|
-
const KEY_NEXT_PARAM = new Set(["\t", "]"]);
|
|
292
|
-
const KEY_PREV_PARAM = new Set(["\u001b[Z", "["]);
|
|
293
|
-
const KEY_ENTER = new Set(["\r", "\n"]);
|
|
294
|
-
const KEY_BACKSPACE = new Set(["\u007f", "\b"]);
|
|
295
|
-
const KEY_OFF = new Set(["x", "\u001b[3~"]);
|
|
298
|
+
/** Tab ] next field, shift-tab [ previous (the brackets are the old keys). */
|
|
299
|
+
const isNextField = (value: string) => KEY_TAB.has(value) || value === "]";
|
|
300
|
+
const isPrevField = (value: string) => KEY_BACKTAB.has(value) || value === "[";
|
|
296
301
|
|
|
297
302
|
export class EuclidEditor {
|
|
298
303
|
private visible = false;
|
|
@@ -394,8 +399,8 @@ export class EuclidEditor {
|
|
|
394
399
|
lanes.length;
|
|
395
400
|
return { type: "handled" };
|
|
396
401
|
}
|
|
397
|
-
if (
|
|
398
|
-
const direction =
|
|
402
|
+
if (isNextField(value) || isPrevField(value)) {
|
|
403
|
+
const direction = isNextField(value) ? 1 : -1;
|
|
399
404
|
this.param =
|
|
400
405
|
(this.param + direction + EUCLID_PARAMS.length) % EUCLID_PARAMS.length;
|
|
401
406
|
return { type: "handled" };
|
|
@@ -437,7 +442,7 @@ export class EuclidEditor {
|
|
|
437
442
|
this.entry = "";
|
|
438
443
|
return { type: "handled" };
|
|
439
444
|
}
|
|
440
|
-
if (
|
|
445
|
+
if (KEY_RESET.has(value))
|
|
441
446
|
return lane.row
|
|
442
447
|
? { type: "run", command: `euclid ${lane.voice} off` }
|
|
443
448
|
: { type: "handled" };
|