@hraness/dawg 0.2.0 → 0.4.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.
Files changed (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Drum patterns and synthesized kits as score edits.
3
+ *
4
+ * /pattern browse the library (picker with preview)
5
+ * /pattern <name> put the pattern on the focused drum track
6
+ * /pattern list the library as text
7
+ * /kit <synth kit> syn808, syn909, acoustic, lofi, electro, trap
8
+ * /kit default the built-in voices again
9
+ *
10
+ * Sample kits (`/kit 909`, `/kit RolandTR808`) stay in `commands/pack.ts`;
11
+ * `/kit` with no name opens one picker listing both kinds.
12
+ *
13
+ * Every edit is one next score, so one revision and one undo step.
14
+ */
15
+ import {
16
+ drumVoicePitch,
17
+ isDrumInstrument,
18
+ parseDrumVoice,
19
+ } from "../../core/drums.ts";
20
+ import { normalizeRhythmRow, type RhythmRow } from "../../core/euclid.ts";
21
+ import { synthKit, SYNTH_KITS } from "../../core/kits.ts";
22
+ import { rhythmVoicePitch, withTrackRhythm } from "../../core/rhythm.ts";
23
+ import {
24
+ isSamplerInstrument,
25
+ samplerVoiceSlots,
26
+ ScoreValidationError,
27
+ TrackScore,
28
+ type Note,
29
+ type Track,
30
+ } from "../../core/score.ts";
31
+ import {
32
+ DRUM_PATTERNS,
33
+ findPattern,
34
+ type DrumPattern,
35
+ } from "../../core/sdk/v1.ts";
36
+
37
+ export { DRUM_PATTERNS, findPattern, type DrumPattern };
38
+
39
+ export type PatternCommand =
40
+ | Readonly<{ kind: "browse" }>
41
+ | Readonly<{ kind: "list" }>
42
+ | Readonly<{ kind: "apply"; name: string; tempo: TempoMode }>;
43
+
44
+ /**
45
+ * `auto` moves the tempo only when it is outside the pattern's range;
46
+ * `keep` never moves it; `set` always uses the pattern's tempo.
47
+ */
48
+ export type TempoMode = "auto" | "keep" | "set";
49
+
50
+ /** `/pattern`, `/pattern list`, `/pattern <name> [keep-tempo|tempo]`. */
51
+ export function parsePatternCommand(
52
+ command: string,
53
+ ): PatternCommand | undefined {
54
+ const match = command
55
+ .trim()
56
+ .match(
57
+ /^\/patterns?(?:\s+([a-z0-9][\w-]{0,40}))?(?:\s+(keep-tempo|keep|tempo))?\s*$/i,
58
+ );
59
+ if (!match) return undefined;
60
+ const name = match[1]?.toLowerCase();
61
+ if (!name) return { kind: "browse" };
62
+ if (name === "list" || name === "ls") return { kind: "list" };
63
+ const flag = match[2]?.toLowerCase();
64
+ return {
65
+ kind: "apply",
66
+ name,
67
+ tempo: flag === "tempo" ? "set" : flag ? "keep" : "auto",
68
+ };
69
+ }
70
+
71
+ /** One line per pattern for `/pattern list` and the agent. */
72
+ export function patternLine(entry: DrumPattern): string {
73
+ const voices = entry.rows.map((row) => row.voice).join(" ");
74
+ return `${entry.name} · ${entry.tempo.min}–${entry.tempo.max} BPM · ${entry.tags.join(", ")} · ${voices}`;
75
+ }
76
+
77
+ export type DrumEdit = Readonly<{
78
+ ok: boolean;
79
+ message: string;
80
+ next?: TrackScore;
81
+ kind?: string;
82
+ payload?: Record<string, unknown>;
83
+ }>;
84
+
85
+ function rowsOf(entry: DrumPattern): RhythmRow[] {
86
+ return entry.rows.map((spec, index) => {
87
+ const { kind: _kind, ...fields } = spec;
88
+ return normalizeRhythmRow(fields, `${entry.name} rows[${index}]`);
89
+ });
90
+ }
91
+
92
+ /**
93
+ * The focused track as a drum track: a kit track as is, a missing or empty
94
+ * melodic track as a new kit track. Undefined for a melodic track with
95
+ * notes or a keyed sampler, which a pattern must not overwrite.
96
+ */
97
+ function drumTrack(
98
+ score: TrackScore,
99
+ trackId: string,
100
+ ): { score: TrackScore; track: Track } | undefined {
101
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
102
+ if (!track) {
103
+ const next = score.withTracks([
104
+ ...score.tracks,
105
+ { id: trackId, name: trackId, instrument: "kit" },
106
+ ]);
107
+ return { score: next, track: next.tracks.at(-1)! };
108
+ }
109
+ if (isDrumInstrument(track.instrument)) return { score, track };
110
+ if (isSamplerInstrument(track.instrument))
111
+ return track.sampler?.mode === "oneshot" ? { score, track } : undefined;
112
+ if (score.notes.some((note) => note.trackId === trackId)) return undefined;
113
+ const next = score.withTracks(
114
+ score.tracks.map((candidate) =>
115
+ candidate.id === trackId
116
+ ? { ...candidate, instrument: "kit", rhythm: null }
117
+ : candidate,
118
+ ),
119
+ );
120
+ return { score: next, track: next.tracks.find((t) => t.id === trackId)! };
121
+ }
122
+
123
+ /**
124
+ * The score with `name` on `trackId`: the track's notes and rows are
125
+ * replaced by the pattern's rows (voices the track lacks are skipped), and
126
+ * the tempo follows `tempo`.
127
+ */
128
+ export function applyDrumPattern(
129
+ score: TrackScore,
130
+ trackId: string,
131
+ name: string,
132
+ tempo: TempoMode = "auto",
133
+ ): DrumEdit {
134
+ const entry = findPattern(name);
135
+ if (!entry)
136
+ return {
137
+ ok: false,
138
+ message: `pattern · no ${name.slice(0, 40)} · /pattern list`,
139
+ };
140
+ const target = drumTrack(score, trackId);
141
+ if (!target)
142
+ return {
143
+ ok: false,
144
+ message: `pattern · ${trackId} is a melodic track · focus a drum track (/track drums) and retry`,
145
+ };
146
+ const rows = rowsOf(entry);
147
+ const playable = rows.filter(
148
+ (row) => rhythmVoicePitch(target.track, row.voice) !== undefined,
149
+ );
150
+ const skipped = rows
151
+ .filter((row) => !playable.includes(row))
152
+ .map((row) => row.voice);
153
+ if (playable.length === 0)
154
+ return {
155
+ ok: false,
156
+ message: `pattern · ${trackId} has none of ${rows.map((row) => row.voice).join("/")}`,
157
+ };
158
+ const moveTempo =
159
+ tempo === "set" ||
160
+ (tempo === "auto" &&
161
+ (score.tempoBpm < entry.tempo.min || score.tempoBpm > entry.tempo.max));
162
+ const cleared = new TrackScore({
163
+ ...target.score.toJSON(),
164
+ ...(moveTempo ? { tempoBpm: entry.tempo.bpm } : {}),
165
+ tracks: target.score.tracks.map((candidate) =>
166
+ candidate.id === trackId ? { ...candidate, rhythm: null } : candidate,
167
+ ),
168
+ notes: target.score.notes.filter((note) => note.trackId !== trackId),
169
+ });
170
+ let next: TrackScore;
171
+ try {
172
+ next = withTrackRhythm(cleared, trackId, playable);
173
+ } catch (error) {
174
+ if (error instanceof ScoreValidationError)
175
+ return { ok: false, message: `pattern · ${error.message}` };
176
+ throw error;
177
+ }
178
+ const parts = [`pattern ${entry.name} on ${trackId}`];
179
+ if (moveTempo) parts.push(`${entry.tempo.bpm} BPM`);
180
+ if (skipped.length) parts.push(`no ${skipped.join("/")}`);
181
+ const track = next.tracks.find((candidate) => candidate.id === trackId);
182
+ if (isDrumInstrument(track?.instrument) && !track?.kit)
183
+ parts.push(`try /kit ${entry.kit}`);
184
+ return {
185
+ ok: true,
186
+ message: parts.join(" · "),
187
+ next,
188
+ kind: "score.pattern",
189
+ payload: {
190
+ trackId,
191
+ pattern: entry.name,
192
+ ...(moveTempo ? { tempoBpm: entry.tempo.bpm } : {}),
193
+ },
194
+ };
195
+ }
196
+
197
+ /** True for names `/kit` should treat as a synthesized kit. */
198
+ export function isSynthKitName(name: string): boolean {
199
+ return (
200
+ name.trim().toLowerCase() === "default" || synthKit(name) !== undefined
201
+ );
202
+ }
203
+
204
+ /**
205
+ * The score with synth kit `name` (`default` for the built-in voices) on
206
+ * `trackId`. A oneshot sampler kit (from `/kit 909`) turns back into a
207
+ * synth kit track; its hits and rows move to the drum voices of the same
208
+ * name, and voices without one are dropped.
209
+ */
210
+ export function applySynthKit(
211
+ score: TrackScore,
212
+ trackId: string,
213
+ name: string,
214
+ ): DrumEdit {
215
+ const isDefault = name.trim().toLowerCase() === "default";
216
+ const kit = isDefault ? undefined : synthKit(name);
217
+ if (!isDefault && !kit)
218
+ return {
219
+ ok: false,
220
+ message: `kit · no synth kit ${name.slice(0, 40)} (default ${SYNTH_KITS.map((entry) => entry.name).join(" ")})`,
221
+ };
222
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
223
+ const label = kit?.name ?? "default";
224
+ const done = (next: TrackScore, extra = ""): DrumEdit => ({
225
+ ok: true,
226
+ message: `kit ${label} on ${trackId}${extra}`,
227
+ next,
228
+ kind: "score.kit",
229
+ payload: { trackId, kit: label },
230
+ });
231
+ if (!track)
232
+ return done(
233
+ score.withTracks([
234
+ ...score.tracks,
235
+ {
236
+ id: trackId,
237
+ name: trackId,
238
+ instrument: "kit",
239
+ ...(kit ? { kit: kit.name } : {}),
240
+ },
241
+ ]),
242
+ );
243
+ if (isDrumInstrument(track.instrument)) {
244
+ if ((track.kit ?? undefined) === kit?.name)
245
+ return { ok: true, message: `kit ${label} on ${trackId} already` };
246
+ return done(
247
+ score.withTracks(
248
+ score.tracks.map((candidate) =>
249
+ candidate.id === trackId
250
+ ? { ...candidate, kit: kit ? kit.name : null }
251
+ : candidate,
252
+ ),
253
+ ),
254
+ );
255
+ }
256
+ const oneshot =
257
+ isSamplerInstrument(track.instrument) && track.sampler?.mode === "oneshot";
258
+ const hasNotes = score.notes.some((note) => note.trackId === trackId);
259
+ if (!oneshot && hasNotes)
260
+ return {
261
+ ok: false,
262
+ message: `kit · ${trackId} is a melodic track · focus a drum track (/track drums) and retry`,
263
+ };
264
+ const pitchOf = new Map<number, number>();
265
+ if (oneshot && track.sampler)
266
+ for (const [voice, slot] of samplerVoiceSlots(track.sampler)) {
267
+ const drum = parseDrumVoice(voice);
268
+ if (drum) pitchOf.set(slot, drumVoicePitch(drum));
269
+ }
270
+ const notes: Note[] = [];
271
+ let dropped = 0;
272
+ for (const note of score.notes) {
273
+ if (note.trackId !== trackId) {
274
+ notes.push(note);
275
+ continue;
276
+ }
277
+ const pitch = pitchOf.get(note.pitch);
278
+ if (pitch === undefined) dropped += 1;
279
+ else notes.push({ ...note, pitch });
280
+ }
281
+ const rows = (track.rhythm ?? []).filter(
282
+ (row) => parseDrumVoice(row.voice) !== undefined,
283
+ );
284
+ const next = new TrackScore({
285
+ ...score.toJSON(),
286
+ tracks: score.tracks.map((candidate) => {
287
+ if (candidate.id !== trackId) return candidate;
288
+ const { sampler: _sampler, ...rest } = candidate;
289
+ return {
290
+ ...rest,
291
+ instrument: "kit",
292
+ rhythm: rows.length > 0 ? rows : null,
293
+ ...(kit ? { kit: kit.name } : {}),
294
+ };
295
+ }),
296
+ notes,
297
+ });
298
+ return done(next, dropped ? ` · ${dropped} hits had no synth voice` : "");
299
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Hand-editing commands the menus emit that the prompt grammar lacked. Each
3
+ * is one ScoreOperation, so it is one revision and one undo step.
4
+ *
5
+ * track name <text> rename the focused track
6
+ * meter <beats per bar> time signature numerator (1..16)
7
+ * key <name>|none song key (`C major`, `F# dorian`)
8
+ * automate <lane> points <beat:value>... merge points into a lane
9
+ * automate <lane> remove <beat> drop the point at a beat
10
+ *
11
+ * Lanes are every AUTOMATION_LANES key plus the effect aliases (`cutoff`,
12
+ * `res`, `feedback`, `delay-fb`). Parsing is pure; applying returns the next
13
+ * score and the session event, leaving persistence to the caller.
14
+ */
15
+ import {
16
+ automationPoints,
17
+ automationRange,
18
+ SCORE_LIMITS,
19
+ applyScoreOperation,
20
+ type AutomationParameter,
21
+ type AutomationPoint,
22
+ type ScoreOperation,
23
+ type TrackScore,
24
+ } from "../../core/score.ts";
25
+ import { keyName, parseKey } from "../../core/chords.ts";
26
+ import { parseEffectLane } from "./music.ts";
27
+
28
+ export type EditCommand =
29
+ | { type: "track-name"; name: string }
30
+ | { type: "meter"; beatsPerBar: number }
31
+ | { type: "key"; key: string | null }
32
+ | {
33
+ type: "automation-points";
34
+ parameter: AutomationParameter;
35
+ points: readonly { beat: number; value: number }[];
36
+ }
37
+ | { type: "automation-remove"; parameter: AutomationParameter; beat: number };
38
+
39
+ export function parseLane(name: string): AutomationParameter | undefined {
40
+ const lower = name.toLowerCase();
41
+ if (lower === "volume" || lower === "vol") return "volume";
42
+ if (lower === "pan") return "pan";
43
+ return parseEffectLane(lower);
44
+ }
45
+
46
+ const NUMBER = /^-?\d+(?:\.\d+)?$/;
47
+
48
+ export function parseEditCommand(prompt: string): EditCommand | undefined {
49
+ const text = prompt.trim().replace(/\s+/g, " ");
50
+ if (text.length > 1_024) return undefined;
51
+ const name = text.match(/^track name (.+)$/i);
52
+ if (name) {
53
+ const value = name[1]!.trim();
54
+ return value.length > 0 && value.length <= SCORE_LIMITS.maxNameLength
55
+ ? { type: "track-name", name: value }
56
+ : undefined;
57
+ }
58
+ const meter = text.match(/^meter (\d+)$/i);
59
+ if (meter) {
60
+ const beatsPerBar = Number(meter[1]);
61
+ return beatsPerBar >= 1 && beatsPerBar <= SCORE_LIMITS.maxBeatsPerBar
62
+ ? { type: "meter", beatsPerBar }
63
+ : undefined;
64
+ }
65
+ const key = text.match(/^\/?key (.+)$/i);
66
+ if (key) {
67
+ const value = key[1]!.trim();
68
+ if (/^(none|off|clear)$/i.test(value)) return { type: "key", key: null };
69
+ const parsed = parseKey(value);
70
+ return parsed ? { type: "key", key: keyName(parsed) } : undefined;
71
+ }
72
+ const points = text.match(/^automate ([a-z-]+) points((?: \S+)+)$/i);
73
+ if (points) {
74
+ const parameter = parseLane(points[1]!);
75
+ if (!parameter) return undefined;
76
+ const { min, max } = automationRange(parameter);
77
+ const parsed: { beat: number; value: number }[] = [];
78
+ for (const pair of points[2]!.trim().split(" ")) {
79
+ const [beatText, valueText, extra] = pair.split(":");
80
+ if (extra !== undefined || !beatText || !valueText) return undefined;
81
+ if (!NUMBER.test(beatText) || !NUMBER.test(valueText)) return undefined;
82
+ const beat = Number(beatText);
83
+ const value = Number(valueText);
84
+ if (beat > SCORE_LIMITS.maxTick) return undefined;
85
+ if (value < min || value > max) return undefined;
86
+ parsed.push({ beat, value });
87
+ }
88
+ if (parsed.length === 0 || parsed.length > SCORE_LIMITS.maxAutomationPoints)
89
+ return undefined;
90
+ return { type: "automation-points", parameter, points: parsed };
91
+ }
92
+ const remove = text.match(/^automate ([a-z-]+) remove (\d+(?:\.\d+)?)$/i);
93
+ if (remove) {
94
+ const parameter = parseLane(remove[1]!);
95
+ return parameter
96
+ ? { type: "automation-remove", parameter, beat: Number(remove[2]) }
97
+ : undefined;
98
+ }
99
+ return undefined;
100
+ }
101
+
102
+ export type EditResult = Readonly<{
103
+ ok: boolean;
104
+ message: string;
105
+ next?: TrackScore;
106
+ kind?: string;
107
+ payload?: Record<string, unknown>;
108
+ }>;
109
+
110
+ /** Apply `command` to `trackId` as one ScoreOperation. */
111
+ export function applyEditCommand(
112
+ score: TrackScore,
113
+ trackId: string,
114
+ command: EditCommand,
115
+ ): EditResult {
116
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
117
+ if (command.type === "meter") {
118
+ const operation: ScoreOperation = {
119
+ type: "setMeter",
120
+ beatsPerBar: command.beatsPerBar,
121
+ };
122
+ return {
123
+ ok: true,
124
+ message: `meter · ${command.beatsPerBar}/4`,
125
+ next: applyScoreOperation(score, operation),
126
+ kind: "score.meter",
127
+ payload: { beatsPerBar: command.beatsPerBar },
128
+ };
129
+ }
130
+ if (command.type === "key") {
131
+ return {
132
+ ok: true,
133
+ message: `key · ${command.key ?? "none"}`,
134
+ next: applyScoreOperation(score, { type: "setKey", key: command.key }),
135
+ kind: "score.key",
136
+ payload: { key: command.key },
137
+ };
138
+ }
139
+ if (!track) return { ok: false, message: `no track ${trackId}` };
140
+ if (command.type === "track-name") {
141
+ const patch = { name: command.name };
142
+ return {
143
+ ok: true,
144
+ message: `track name · ${command.name}`,
145
+ next: applyScoreOperation(score, {
146
+ type: "updateTrack",
147
+ trackId,
148
+ patch,
149
+ }),
150
+ kind: "score.track",
151
+ payload: { trackId, patch },
152
+ };
153
+ }
154
+ const current: readonly AutomationPoint[] = automationPoints(
155
+ track,
156
+ command.parameter,
157
+ );
158
+ let points: AutomationPoint[];
159
+ if (command.type === "automation-points") {
160
+ const byTick = new Map(current.map((point) => [point.tick, point]));
161
+ for (const point of command.points) {
162
+ const tick = Math.max(0, Math.round(point.beat * score.ticksPerBeat));
163
+ byTick.set(tick, { tick, value: point.value });
164
+ }
165
+ points = [...byTick.values()].sort((left, right) => left.tick - right.tick);
166
+ if (points.length > SCORE_LIMITS.maxAutomationPoints)
167
+ return {
168
+ ok: false,
169
+ message: `${command.parameter} automation holds ${SCORE_LIMITS.maxAutomationPoints} points`,
170
+ };
171
+ } else {
172
+ const tick = Math.round(command.beat * score.ticksPerBeat);
173
+ points = current.filter((point) => point.tick !== tick);
174
+ if (points.length === current.length)
175
+ return {
176
+ ok: false,
177
+ message: `no ${command.parameter} point at beat ${command.beat}`,
178
+ };
179
+ }
180
+ return {
181
+ ok: true,
182
+ message: `automation · ${command.parameter} ${points.length} point${points.length === 1 ? "" : "s"}`,
183
+ next: applyScoreOperation(score, {
184
+ type: "setAutomation",
185
+ trackId,
186
+ parameter: command.parameter,
187
+ points,
188
+ }),
189
+ kind: "score.automation",
190
+ payload: { trackId, parameter: command.parameter, points },
191
+ };
192
+ }