@hraness/dawg 0.0.0-stage → 0.3.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 +327 -0
- package/LICENSE +21 -0
- package/README.md +213 -2
- package/core/diff.ts +249 -0
- package/core/drums.ts +102 -0
- package/core/key.ts +43 -0
- package/core/loop.ts +78 -0
- package/core/pitch.ts +60 -0
- package/core/score.ts +1388 -0
- package/core/sdk/eval-child.ts +113 -0
- package/core/sdk/eval.ts +257 -0
- package/core/sdk/print.ts +393 -0
- package/core/sdk/v1.ts +954 -0
- package/core/slug.ts +19 -0
- package/package.json +45 -4
- package/src/agent/agent.ts +853 -0
- package/src/agent/brief.ts +160 -0
- package/src/agent/gateway.ts +441 -0
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +157 -0
- package/src/agent/planner.ts +259 -0
- package/src/agent/provider.ts +454 -0
- package/src/agent/sse.ts +114 -0
- package/src/agent/tools.ts +1373 -0
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +262 -0
- package/src/agent/xcb.ts +579 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/clock.ts +68 -0
- package/src/audio/engine.ts +841 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/lock.ts +57 -0
- package/src/audio/player.ts +134 -0
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +292 -0
- package/src/audio/samples.ts +683 -0
- package/src/audio/wav.ts +861 -0
- package/src/auth/cli.ts +231 -0
- package/src/auth/credentials.ts +411 -0
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +1191 -0
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +207 -0
- package/src/auth/tui.ts +107 -0
- package/src/commands/edit.ts +170 -0
- package/src/commands/help.ts +247 -0
- package/src/commands/history.ts +69 -0
- package/src/commands/music.ts +461 -0
- package/src/commands/sample.ts +302 -0
- package/src/daemon.ts +31 -0
- package/src/main.ts +2209 -0
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +173 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +292 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +121 -0
- package/src/session/attach.ts +181 -0
- package/src/session/client.ts +498 -0
- package/src/session/daemon.ts +740 -0
- package/src/session/delta.ts +249 -0
- package/src/session/list.ts +180 -0
- package/src/session/lock.ts +92 -0
- package/src/session/meta.ts +253 -0
- package/src/session/naming.ts +430 -0
- package/src/session/port.ts +481 -0
- package/src/session/presence.ts +159 -0
- package/src/session/protocol.ts +618 -0
- package/src/session/rebase.ts +168 -0
- package/src/session/store.ts +581 -0
- package/src/tui/menu.ts +1083 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +636 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +681 -0
- package/tui/activity.ts +364 -0
- package/tui/app.ts +1372 -0
- package/tui/drums.ts +65 -0
- package/tui/highway.ts +921 -0
- package/tui/input.ts +63 -0
- package/tui/keys.ts +102 -0
- package/tui/layers.ts +80 -0
- package/tui/play-strip.ts +143 -0
- package/tui/prompt.ts +609 -0
- package/tui/render.ts +124 -0
- package/tui/screen.ts +247 -0
- package/tui/text.ts +72 -0
- package/tui/theme.ts +451 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct music commands for drums, effects, and solo.
|
|
3
|
+
*
|
|
4
|
+
* Grammar (case-insensitive, one command per prompt; beats are score beats):
|
|
5
|
+
*
|
|
6
|
+
* hit <voice> [at] <beat> [vel <0..1>]
|
|
7
|
+
* pattern <voice> <beat> [<beat> ...] [vel <0..1>] up to 64 beats
|
|
8
|
+
* pattern <voice> every <step> [from <beat>] [vel <0..1>] fills the loop
|
|
9
|
+
* clear <voice> drum hits only
|
|
10
|
+
* filter <cutoff hz> [<resonance 0..1>] | filter off
|
|
11
|
+
* delay <beats> [<feedback 0..0.9> [<mix 0..1>]] | delay off
|
|
12
|
+
* reverb <mix 0..1> [<size 0..1>] | reverb off
|
|
13
|
+
* automate <lane> at <beat> <value> | clear <lane> automation
|
|
14
|
+
* lanes: filter (cutoff hz), resonance (0..1), delay-feedback (0..0.9),
|
|
15
|
+
* delay-mix (0..1); volume and pan lanes live in the core prompt parser
|
|
16
|
+
* solo | unsolo
|
|
17
|
+
*
|
|
18
|
+
* Voices: kick (bd), snare (sd), clap (cp), rim (perc), tom, hat (hh),
|
|
19
|
+
* openhat (oh). Drum commands require a kit track (`instrument kit`).
|
|
20
|
+
*
|
|
21
|
+
* Parsing is pure and bounded; applying returns the next immutable score
|
|
22
|
+
* plus the session event kind/payload, leaving persistence to the caller.
|
|
23
|
+
*/
|
|
24
|
+
import {
|
|
25
|
+
SCORE_LIMITS,
|
|
26
|
+
createScore,
|
|
27
|
+
updateTrack,
|
|
28
|
+
setTrackAutomation,
|
|
29
|
+
AUTOMATION_LANES,
|
|
30
|
+
type AutomationParameter,
|
|
31
|
+
type NoteInput,
|
|
32
|
+
type TrackScore,
|
|
33
|
+
} from "../../core/score.ts";
|
|
34
|
+
import {
|
|
35
|
+
drumVoiceForPitch,
|
|
36
|
+
drumVoicePitch,
|
|
37
|
+
isDrumInstrument,
|
|
38
|
+
parseDrumVoice,
|
|
39
|
+
type DrumVoice,
|
|
40
|
+
} from "../../core/drums.ts";
|
|
41
|
+
|
|
42
|
+
export const MAX_PATTERN_BEATS = 64;
|
|
43
|
+
export const MAX_PATTERN_HITS = 256;
|
|
44
|
+
export const MIN_PATTERN_STEP = 0.125;
|
|
45
|
+
const DEFAULT_HIT_VELOCITY = 0.9;
|
|
46
|
+
|
|
47
|
+
export type MusicCommand =
|
|
48
|
+
| {
|
|
49
|
+
type: "drum-hits";
|
|
50
|
+
voice: DrumVoice;
|
|
51
|
+
beats: readonly number[];
|
|
52
|
+
velocity: number;
|
|
53
|
+
}
|
|
54
|
+
| {
|
|
55
|
+
type: "drum-every";
|
|
56
|
+
voice: DrumVoice;
|
|
57
|
+
step: number;
|
|
58
|
+
from: number;
|
|
59
|
+
velocity: number;
|
|
60
|
+
}
|
|
61
|
+
| { type: "drum-clear"; voice: DrumVoice }
|
|
62
|
+
| { type: "filter"; cutoff: number; resonance: number }
|
|
63
|
+
| { type: "filter-off" }
|
|
64
|
+
| { type: "delay"; beats: number; feedback: number; mix: number }
|
|
65
|
+
| { type: "delay-off" }
|
|
66
|
+
| { type: "reverb"; mix: number; size: number }
|
|
67
|
+
| { type: "reverb-off" }
|
|
68
|
+
| {
|
|
69
|
+
type: "effect-automation";
|
|
70
|
+
parameter: EffectAutomationParameter;
|
|
71
|
+
beat: number;
|
|
72
|
+
value: number;
|
|
73
|
+
}
|
|
74
|
+
| { type: "effect-automation-clear"; parameter: EffectAutomationParameter }
|
|
75
|
+
| { type: "solo"; solo: boolean };
|
|
76
|
+
|
|
77
|
+
/** Automation lanes owned by the effect grammar (volume/pan are core). */
|
|
78
|
+
export type EffectAutomationParameter = Exclude<
|
|
79
|
+
AutomationParameter,
|
|
80
|
+
"volume" | "pan"
|
|
81
|
+
>;
|
|
82
|
+
|
|
83
|
+
const LANE_ALIASES: Readonly<Record<string, EffectAutomationParameter>> =
|
|
84
|
+
Object.freeze({
|
|
85
|
+
filter: "filter",
|
|
86
|
+
cutoff: "filter",
|
|
87
|
+
resonance: "resonance",
|
|
88
|
+
res: "resonance",
|
|
89
|
+
"filter-resonance": "resonance",
|
|
90
|
+
"delay-feedback": "delay-feedback",
|
|
91
|
+
"delay-fb": "delay-feedback",
|
|
92
|
+
feedback: "delay-feedback",
|
|
93
|
+
"delay-mix": "delay-mix",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export function parseEffectLane(
|
|
97
|
+
name: string,
|
|
98
|
+
): EffectAutomationParameter | undefined {
|
|
99
|
+
return Object.prototype.hasOwnProperty.call(LANE_ALIASES, name)
|
|
100
|
+
? LANE_ALIASES[name]
|
|
101
|
+
: undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type MusicResult = Readonly<{
|
|
105
|
+
/** `true` when the command applied; `false` carries a failure message. */
|
|
106
|
+
ok: boolean;
|
|
107
|
+
message: string;
|
|
108
|
+
next?: TrackScore;
|
|
109
|
+
kind?: string;
|
|
110
|
+
payload?: Record<string, unknown>;
|
|
111
|
+
}>;
|
|
112
|
+
|
|
113
|
+
const NUMBER = "(\\d+(?:\\.\\d+)?)";
|
|
114
|
+
const VELOCITY = `(?:\\s+(?:vel|velocity)\\s+${NUMBER})?`;
|
|
115
|
+
|
|
116
|
+
export function parseMusicCommand(prompt: string): MusicCommand | undefined {
|
|
117
|
+
const text = prompt.trim().toLowerCase().replace(/\s+/g, " ");
|
|
118
|
+
if (text.length > 1_024) return undefined;
|
|
119
|
+
if (text === "solo") return { type: "solo", solo: true };
|
|
120
|
+
if (text === "unsolo") return { type: "solo", solo: false };
|
|
121
|
+
|
|
122
|
+
const hit = text.match(
|
|
123
|
+
new RegExp(`^hit ([a-z-]+) (?:at )?${NUMBER}${VELOCITY}$`),
|
|
124
|
+
);
|
|
125
|
+
if (hit) {
|
|
126
|
+
const voice = parseDrumVoice(hit[1]!);
|
|
127
|
+
const beat = Number(hit[2]);
|
|
128
|
+
const velocity = parseVelocity(hit[3]);
|
|
129
|
+
if (!voice || !validBeat(beat) || velocity === undefined) return undefined;
|
|
130
|
+
return { type: "drum-hits", voice, beats: [beat], velocity };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const every = text.match(
|
|
134
|
+
new RegExp(
|
|
135
|
+
`^pattern ([a-z-]+) every ${NUMBER}(?: from ${NUMBER})?${VELOCITY}$`,
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
if (every) {
|
|
139
|
+
const voice = parseDrumVoice(every[1]!);
|
|
140
|
+
const step = Number(every[2]);
|
|
141
|
+
const from = every[3] === undefined ? 0 : Number(every[3]);
|
|
142
|
+
const velocity = parseVelocity(every[4]);
|
|
143
|
+
if (
|
|
144
|
+
!voice ||
|
|
145
|
+
!Number.isFinite(step) ||
|
|
146
|
+
step < MIN_PATTERN_STEP ||
|
|
147
|
+
step > SCORE_LIMITS.maxBars * SCORE_LIMITS.maxBeatsPerBar ||
|
|
148
|
+
!validBeat(from) ||
|
|
149
|
+
velocity === undefined
|
|
150
|
+
)
|
|
151
|
+
return undefined;
|
|
152
|
+
return { type: "drum-every", voice, step, from, velocity };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const pattern = text.match(
|
|
156
|
+
new RegExp(`^pattern ([a-z-]+)((?: ${NUMBER})+)${VELOCITY}$`),
|
|
157
|
+
);
|
|
158
|
+
if (pattern) {
|
|
159
|
+
const voice = parseDrumVoice(pattern[1]!);
|
|
160
|
+
const beats = pattern[2]!.trim().split(" ").map(Number);
|
|
161
|
+
const velocity = parseVelocity(pattern[4]);
|
|
162
|
+
if (
|
|
163
|
+
!voice ||
|
|
164
|
+
beats.length > MAX_PATTERN_BEATS ||
|
|
165
|
+
!beats.every(validBeat) ||
|
|
166
|
+
velocity === undefined
|
|
167
|
+
)
|
|
168
|
+
return undefined;
|
|
169
|
+
return {
|
|
170
|
+
type: "drum-hits",
|
|
171
|
+
voice,
|
|
172
|
+
beats: [...new Set(beats)].sort((a, b) => a - b),
|
|
173
|
+
velocity,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const clear = text.match(/^clear ([a-z-]+)$/);
|
|
178
|
+
if (clear) {
|
|
179
|
+
const voice = parseDrumVoice(clear[1]!);
|
|
180
|
+
if (voice) return { type: "drum-clear", voice };
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (/^(?:filter|lowpass) off$/.test(text)) return { type: "filter-off" };
|
|
185
|
+
const filter = text.match(
|
|
186
|
+
new RegExp(`^(?:filter|lowpass) ${NUMBER}(?: ${NUMBER})?$`),
|
|
187
|
+
);
|
|
188
|
+
if (filter) {
|
|
189
|
+
const cutoff = Number(filter[1]);
|
|
190
|
+
const resonance = filter[2] === undefined ? 0 : Number(filter[2]);
|
|
191
|
+
if (
|
|
192
|
+
!validCutoff(cutoff) ||
|
|
193
|
+
!Number.isFinite(resonance) ||
|
|
194
|
+
resonance > SCORE_LIMITS.maxFilterResonance
|
|
195
|
+
)
|
|
196
|
+
return undefined;
|
|
197
|
+
return { type: "filter", cutoff, resonance };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (text === "delay off") return { type: "delay-off" };
|
|
201
|
+
const delay = text.match(
|
|
202
|
+
new RegExp(`^delay ${NUMBER}(?: ${NUMBER}(?: ${NUMBER})?)?$`),
|
|
203
|
+
);
|
|
204
|
+
if (delay) {
|
|
205
|
+
const beats = Number(delay[1]);
|
|
206
|
+
const feedback = delay[2] === undefined ? 0.3 : Number(delay[2]);
|
|
207
|
+
const mix = delay[3] === undefined ? 0.35 : Number(delay[3]);
|
|
208
|
+
if (
|
|
209
|
+
!Number.isFinite(beats) ||
|
|
210
|
+
beats < SCORE_LIMITS.minDelayBeats ||
|
|
211
|
+
beats > SCORE_LIMITS.maxDelayBeats ||
|
|
212
|
+
!Number.isFinite(feedback) ||
|
|
213
|
+
feedback > SCORE_LIMITS.maxDelayFeedback ||
|
|
214
|
+
!Number.isFinite(mix) ||
|
|
215
|
+
mix > SCORE_LIMITS.maxDelayMix
|
|
216
|
+
)
|
|
217
|
+
return undefined;
|
|
218
|
+
return { type: "delay", beats, feedback, mix };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (/^reverb off$/.test(text)) return { type: "reverb-off" };
|
|
222
|
+
const reverb = text.match(new RegExp(`^reverb ${NUMBER}(?: ${NUMBER})?$`));
|
|
223
|
+
if (reverb) {
|
|
224
|
+
const mix = Number(reverb[1]);
|
|
225
|
+
const size = reverb[2] === undefined ? 0.5 : Number(reverb[2]);
|
|
226
|
+
if (
|
|
227
|
+
!Number.isFinite(mix) ||
|
|
228
|
+
mix > SCORE_LIMITS.maxReverbMix ||
|
|
229
|
+
!Number.isFinite(size) ||
|
|
230
|
+
size > SCORE_LIMITS.maxReverbSize
|
|
231
|
+
)
|
|
232
|
+
return undefined;
|
|
233
|
+
return { type: "reverb", mix, size };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const clearLane = text.match(/^(?:clear|reset) ([a-z-]+) automation$/);
|
|
237
|
+
if (clearLane) {
|
|
238
|
+
const parameter = parseEffectLane(clearLane[1]!);
|
|
239
|
+
return parameter
|
|
240
|
+
? { type: "effect-automation-clear", parameter }
|
|
241
|
+
: undefined;
|
|
242
|
+
}
|
|
243
|
+
const automate = text.match(
|
|
244
|
+
new RegExp(`^(?:automate|automation) ([a-z-]+) at ${NUMBER} ${NUMBER}$`),
|
|
245
|
+
);
|
|
246
|
+
if (automate) {
|
|
247
|
+
const parameter = parseEffectLane(automate[1]!);
|
|
248
|
+
const beat = Number(automate[2]);
|
|
249
|
+
const value = Number(automate[3]);
|
|
250
|
+
if (!parameter || !validBeat(beat)) return undefined;
|
|
251
|
+
const { min, max } = AUTOMATION_LANES[parameter];
|
|
252
|
+
if (!Number.isFinite(value) || value < min || value > max) return undefined;
|
|
253
|
+
return { type: "effect-automation", parameter, beat, value };
|
|
254
|
+
}
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Apply a parsed command to `trackId`. `newId(index)` must return a unique
|
|
260
|
+
* note id for each drum hit created by this command.
|
|
261
|
+
*/
|
|
262
|
+
export function applyMusicCommand(
|
|
263
|
+
score: TrackScore,
|
|
264
|
+
trackId: string,
|
|
265
|
+
command: MusicCommand,
|
|
266
|
+
newId: (index: number) => string,
|
|
267
|
+
): MusicResult {
|
|
268
|
+
const track = score.tracks.find((candidate) => candidate.id === trackId);
|
|
269
|
+
if (!track) return { ok: false, message: `no track · ${trackId}` };
|
|
270
|
+
|
|
271
|
+
if (command.type === "solo") {
|
|
272
|
+
const next = updateTrack(score, trackId, { solo: command.solo });
|
|
273
|
+
return {
|
|
274
|
+
ok: true,
|
|
275
|
+
message: `${command.solo ? "solo" : "unsolo"} · ${trackId}`,
|
|
276
|
+
next,
|
|
277
|
+
kind: "score.track",
|
|
278
|
+
payload: { trackId, patch: { solo: command.solo } },
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if (command.type === "filter" || command.type === "filter-off") {
|
|
282
|
+
const filter =
|
|
283
|
+
command.type === "filter"
|
|
284
|
+
? { cutoff: command.cutoff, resonance: command.resonance }
|
|
285
|
+
: null;
|
|
286
|
+
return {
|
|
287
|
+
ok: true,
|
|
288
|
+
message: filter
|
|
289
|
+
? `filter · ${filter.cutoff} Hz res ${filter.resonance}`
|
|
290
|
+
: "filter · off",
|
|
291
|
+
next: updateTrack(score, trackId, { filter }),
|
|
292
|
+
kind: "score.effect",
|
|
293
|
+
payload: { trackId, effect: "filter", value: filter },
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
if (command.type === "delay" || command.type === "delay-off") {
|
|
297
|
+
const delay =
|
|
298
|
+
command.type === "delay"
|
|
299
|
+
? { beats: command.beats, feedback: command.feedback, mix: command.mix }
|
|
300
|
+
: null;
|
|
301
|
+
return {
|
|
302
|
+
ok: true,
|
|
303
|
+
message: delay
|
|
304
|
+
? `delay · ${delay.beats} beats fb ${delay.feedback} mix ${delay.mix}`
|
|
305
|
+
: "delay · off",
|
|
306
|
+
next: updateTrack(score, trackId, { delay }),
|
|
307
|
+
kind: "score.effect",
|
|
308
|
+
payload: { trackId, effect: "delay", value: delay },
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
if (command.type === "reverb" || command.type === "reverb-off") {
|
|
312
|
+
const reverb =
|
|
313
|
+
command.type === "reverb"
|
|
314
|
+
? { mix: command.mix, size: command.size }
|
|
315
|
+
: null;
|
|
316
|
+
return {
|
|
317
|
+
ok: true,
|
|
318
|
+
message: reverb
|
|
319
|
+
? `reverb · mix ${reverb.mix} size ${reverb.size}`
|
|
320
|
+
: "reverb · off",
|
|
321
|
+
next: updateTrack(score, trackId, { reverb }),
|
|
322
|
+
kind: "score.effect",
|
|
323
|
+
payload: { trackId, effect: "reverb", value: reverb },
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (
|
|
327
|
+
command.type === "effect-automation" ||
|
|
328
|
+
command.type === "effect-automation-clear"
|
|
329
|
+
) {
|
|
330
|
+
const { parameter } = command;
|
|
331
|
+
let points: { tick: number; value: number }[] = [];
|
|
332
|
+
if (command.type === "effect-automation") {
|
|
333
|
+
const tick = Math.round(command.beat * score.ticksPerBeat);
|
|
334
|
+
const merged = new Map(
|
|
335
|
+
(track[AUTOMATION_LANES[parameter].field] ?? []).map((point) => [
|
|
336
|
+
point.tick,
|
|
337
|
+
point,
|
|
338
|
+
]),
|
|
339
|
+
);
|
|
340
|
+
merged.set(tick, { tick, value: command.value });
|
|
341
|
+
points = [...merged.values()].sort((a, b) => a.tick - b.tick);
|
|
342
|
+
if (points.length > SCORE_LIMITS.maxAutomationPoints)
|
|
343
|
+
return { ok: false, message: `${parameter} automation is full` };
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
ok: true,
|
|
347
|
+
message: `automation · ${parameter} ${points.length} point${points.length === 1 ? "" : "s"}`,
|
|
348
|
+
next: setTrackAutomation(score, trackId, parameter, points),
|
|
349
|
+
kind: "score.automation",
|
|
350
|
+
payload: { trackId, parameter, points },
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (!isDrumInstrument(track.instrument))
|
|
355
|
+
return {
|
|
356
|
+
ok: false,
|
|
357
|
+
message: `${trackId} is not a drum track · try instrument kit`,
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
if (command.type === "drum-clear") {
|
|
361
|
+
const removed = score.notes.filter(
|
|
362
|
+
(note) =>
|
|
363
|
+
note.trackId === trackId &&
|
|
364
|
+
drumVoiceForPitch(note.pitch) === command.voice,
|
|
365
|
+
);
|
|
366
|
+
if (removed.length === 0)
|
|
367
|
+
return { ok: false, message: `no ${command.voice} hits` };
|
|
368
|
+
const next = createScore({
|
|
369
|
+
...score.toJSON(),
|
|
370
|
+
notes: score.notes.filter((note) => !removed.includes(note)),
|
|
371
|
+
});
|
|
372
|
+
return {
|
|
373
|
+
ok: true,
|
|
374
|
+
message: `cleared ${removed.length} ${command.voice} hit${removed.length === 1 ? "" : "s"}`,
|
|
375
|
+
next,
|
|
376
|
+
kind: "score.drums",
|
|
377
|
+
payload: { trackId, voice: command.voice, removed: removed.length },
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const beats =
|
|
382
|
+
command.type === "drum-hits"
|
|
383
|
+
? command.beats
|
|
384
|
+
: everyBeats(command.from, command.step, score.bars * score.beatsPerBar);
|
|
385
|
+
if (beats.length > MAX_PATTERN_HITS)
|
|
386
|
+
return { ok: false, message: `pattern exceeds ${MAX_PATTERN_HITS} hits` };
|
|
387
|
+
const pitch = drumVoicePitch(command.voice);
|
|
388
|
+
const existing = new Set(
|
|
389
|
+
score.notes
|
|
390
|
+
.filter((note) => note.trackId === trackId && note.pitch === pitch)
|
|
391
|
+
.map((note) => note.startTick),
|
|
392
|
+
);
|
|
393
|
+
const durationTicks = Math.max(1, Math.round(score.ticksPerBeat / 4));
|
|
394
|
+
const hits: NoteInput[] = [];
|
|
395
|
+
for (const beat of beats) {
|
|
396
|
+
const startTick = Math.round(beat * score.ticksPerBeat);
|
|
397
|
+
if (startTick > SCORE_LIMITS.maxTick || existing.has(startTick)) continue;
|
|
398
|
+
existing.add(startTick);
|
|
399
|
+
hits.push({
|
|
400
|
+
id: newId(hits.length),
|
|
401
|
+
trackId,
|
|
402
|
+
startTick,
|
|
403
|
+
durationTicks,
|
|
404
|
+
pitch,
|
|
405
|
+
velocity: command.velocity,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
if (hits.length === 0)
|
|
409
|
+
return { ok: false, message: `${command.voice} already there` };
|
|
410
|
+
if (score.notes.length + hits.length > SCORE_LIMITS.maxNotes)
|
|
411
|
+
return {
|
|
412
|
+
ok: false,
|
|
413
|
+
message: `score is full (${SCORE_LIMITS.maxNotes} notes)`,
|
|
414
|
+
};
|
|
415
|
+
const next = createScore({
|
|
416
|
+
...score.toJSON(),
|
|
417
|
+
notes: [...score.notes, ...hits],
|
|
418
|
+
});
|
|
419
|
+
return {
|
|
420
|
+
ok: true,
|
|
421
|
+
message: `+${hits.length} ${command.voice} hit${hits.length === 1 ? "" : "s"}`,
|
|
422
|
+
next,
|
|
423
|
+
kind: "score.drums",
|
|
424
|
+
payload: {
|
|
425
|
+
trackId,
|
|
426
|
+
voice: command.voice,
|
|
427
|
+
ticks: hits.map((hit) => hit.startTick),
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function everyBeats(from: number, step: number, loopBeats: number): number[] {
|
|
433
|
+
const beats: number[] = [];
|
|
434
|
+
// Index-based stepping avoids accumulating floating-point drift.
|
|
435
|
+
for (let index = 0; beats.length <= MAX_PATTERN_HITS; index += 1) {
|
|
436
|
+
const beat = from + index * step;
|
|
437
|
+
if (beat >= loopBeats) break;
|
|
438
|
+
beats.push(beat);
|
|
439
|
+
}
|
|
440
|
+
return beats;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function parseVelocity(value: string | undefined): number | undefined {
|
|
444
|
+
if (value === undefined) return DEFAULT_HIT_VELOCITY;
|
|
445
|
+
const velocity = Number(value);
|
|
446
|
+
return Number.isFinite(velocity) && velocity <= 1 ? velocity : undefined;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function validBeat(beat: number): boolean {
|
|
450
|
+
return (
|
|
451
|
+
Number.isFinite(beat) && beat >= 0 && beat * 4_096 <= SCORE_LIMITS.maxTick
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function validCutoff(cutoff: number): boolean {
|
|
456
|
+
return (
|
|
457
|
+
Number.isFinite(cutoff) &&
|
|
458
|
+
cutoff >= SCORE_LIMITS.minFilterCutoff &&
|
|
459
|
+
cutoff <= SCORE_LIMITS.maxFilterCutoff
|
|
460
|
+
);
|
|
461
|
+
}
|