@hraness/dawg 0.0.0-stage → 0.2.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 +47 -0
- package/DAWG.md +142 -0
- package/LICENSE +21 -0
- package/README.md +164 -2
- package/core/drums.ts +102 -0
- package/core/loop.ts +78 -0
- package/core/score.ts +1065 -0
- package/package.json +42 -4
- package/src/agent/agent.ts +587 -0
- package/src/agent/brief.ts +140 -0
- package/src/agent/gateway.ts +267 -0
- package/src/agent/ops.ts +172 -0
- package/src/agent/planner.ts +221 -0
- package/src/agent/provider.ts +326 -0
- package/src/agent/sse.ts +90 -0
- package/src/agent/tools.ts +976 -0
- package/src/agent/xcb-agent.ts +247 -0
- package/src/agent/xcb.ts +367 -0
- package/src/audio/clock.ts +68 -0
- package/src/audio/engine.ts +467 -0
- package/src/audio/lock.ts +57 -0
- package/src/audio/player.ts +119 -0
- package/src/audio/wav.ts +647 -0
- package/src/auth/cli.ts +112 -0
- package/src/auth/credentials.ts +285 -0
- package/src/auth/login.ts +434 -0
- package/src/auth/runner.ts +184 -0
- package/src/auth/tui.ts +90 -0
- package/src/commands/history.ts +56 -0
- package/src/commands/music.ts +444 -0
- package/src/daemon.ts +31 -0
- package/src/main.ts +1332 -0
- package/src/render.ts +99 -0
- package/src/session/attach.ts +182 -0
- package/src/session/client.ts +498 -0
- package/src/session/daemon.ts +723 -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 +464 -0
- package/src/session/port.ts +461 -0
- package/src/session/presence.ts +159 -0
- package/src/session/protocol.ts +618 -0
- package/src/session/rebase.ts +138 -0
- package/src/session/store.ts +486 -0
- package/tui/activity.ts +325 -0
- package/tui/app.ts +1129 -0
- package/tui/drums.ts +21 -0
- package/tui/highway.ts +905 -0
- package/tui/input.ts +63 -0
- package/tui/keys.ts +102 -0
- package/tui/layers.ts +68 -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,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Undo/redo over the shared session event log.
|
|
3
|
+
*
|
|
4
|
+
* Every score edit records `before`, the composition it replaced. Undo and
|
|
5
|
+
* redo append ordinary events (`score.undo` / `score.redo`) whose `before` is
|
|
6
|
+
* the composition they replaced, so history is linear, shared by every
|
|
7
|
+
* window, and survives restarts. Replaying the log rebuilds two stacks:
|
|
8
|
+
*
|
|
9
|
+
* edit -> push onto undo, clear redo
|
|
10
|
+
* undo -> pop undo, push the undo event onto redo
|
|
11
|
+
* redo -> pop redo, push the redo event onto undo
|
|
12
|
+
*
|
|
13
|
+
* Undoing restores the top undo entry's `before`; redoing restores the top
|
|
14
|
+
* redo entry's `before` (the composition the undo replaced).
|
|
15
|
+
*/
|
|
16
|
+
export type HistoryEvent = Readonly<{
|
|
17
|
+
revision: number;
|
|
18
|
+
kind: string;
|
|
19
|
+
payload: unknown;
|
|
20
|
+
}>;
|
|
21
|
+
|
|
22
|
+
export type HistoryTarget = Readonly<{
|
|
23
|
+
/** Revision of the event being undone or redone. */
|
|
24
|
+
revision: number;
|
|
25
|
+
/** Composition to restore. */
|
|
26
|
+
composition: unknown;
|
|
27
|
+
}>;
|
|
28
|
+
|
|
29
|
+
export const UNDO_KIND = "score.undo";
|
|
30
|
+
export const REDO_KIND = "score.redo";
|
|
31
|
+
|
|
32
|
+
export function historyTarget(
|
|
33
|
+
events: readonly HistoryEvent[],
|
|
34
|
+
direction: "undo" | "redo",
|
|
35
|
+
): HistoryTarget | undefined {
|
|
36
|
+
const undo: HistoryEvent[] = [];
|
|
37
|
+
const redo: HistoryEvent[] = [];
|
|
38
|
+
for (const event of events) {
|
|
39
|
+
if (!hasBefore(event.payload)) continue;
|
|
40
|
+
if (event.kind === UNDO_KIND) {
|
|
41
|
+
if (undo.pop()) redo.push(event);
|
|
42
|
+
} else if (event.kind === REDO_KIND) {
|
|
43
|
+
if (redo.pop()) undo.push(event);
|
|
44
|
+
} else {
|
|
45
|
+
undo.push(event);
|
|
46
|
+
redo.length = 0;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const top = (direction === "undo" ? undo : redo).at(-1);
|
|
50
|
+
if (!top || !hasBefore(top.payload)) return undefined;
|
|
51
|
+
return { revision: top.revision, composition: top.payload.before };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function hasBefore(payload: unknown): payload is { before: unknown } {
|
|
55
|
+
return typeof payload === "object" && payload !== null && "before" in payload;
|
|
56
|
+
}
|
|
@@ -0,0 +1,444 @@
|
|
|
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
|
+
message: string;
|
|
106
|
+
next?: TrackScore;
|
|
107
|
+
kind?: string;
|
|
108
|
+
payload?: Record<string, unknown>;
|
|
109
|
+
}>;
|
|
110
|
+
|
|
111
|
+
const NUMBER = "(\\d+(?:\\.\\d+)?)";
|
|
112
|
+
const VELOCITY = `(?:\\s+(?:vel|velocity)\\s+${NUMBER})?`;
|
|
113
|
+
|
|
114
|
+
export function parseMusicCommand(prompt: string): MusicCommand | undefined {
|
|
115
|
+
const text = prompt.trim().toLowerCase().replace(/\s+/g, " ");
|
|
116
|
+
if (text.length > 1_024) return undefined;
|
|
117
|
+
if (text === "solo") return { type: "solo", solo: true };
|
|
118
|
+
if (text === "unsolo") return { type: "solo", solo: false };
|
|
119
|
+
|
|
120
|
+
const hit = text.match(
|
|
121
|
+
new RegExp(`^hit ([a-z-]+) (?:at )?${NUMBER}${VELOCITY}$`),
|
|
122
|
+
);
|
|
123
|
+
if (hit) {
|
|
124
|
+
const voice = parseDrumVoice(hit[1]!);
|
|
125
|
+
const beat = Number(hit[2]);
|
|
126
|
+
const velocity = parseVelocity(hit[3]);
|
|
127
|
+
if (!voice || !validBeat(beat) || velocity === undefined) return undefined;
|
|
128
|
+
return { type: "drum-hits", voice, beats: [beat], velocity };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const every = text.match(
|
|
132
|
+
new RegExp(
|
|
133
|
+
`^pattern ([a-z-]+) every ${NUMBER}(?: from ${NUMBER})?${VELOCITY}$`,
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
if (every) {
|
|
137
|
+
const voice = parseDrumVoice(every[1]!);
|
|
138
|
+
const step = Number(every[2]);
|
|
139
|
+
const from = every[3] === undefined ? 0 : Number(every[3]);
|
|
140
|
+
const velocity = parseVelocity(every[4]);
|
|
141
|
+
if (
|
|
142
|
+
!voice ||
|
|
143
|
+
!Number.isFinite(step) ||
|
|
144
|
+
step < MIN_PATTERN_STEP ||
|
|
145
|
+
step > SCORE_LIMITS.maxBars * SCORE_LIMITS.maxBeatsPerBar ||
|
|
146
|
+
!validBeat(from) ||
|
|
147
|
+
velocity === undefined
|
|
148
|
+
)
|
|
149
|
+
return undefined;
|
|
150
|
+
return { type: "drum-every", voice, step, from, velocity };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const pattern = text.match(
|
|
154
|
+
new RegExp(`^pattern ([a-z-]+)((?: ${NUMBER})+)${VELOCITY}$`),
|
|
155
|
+
);
|
|
156
|
+
if (pattern) {
|
|
157
|
+
const voice = parseDrumVoice(pattern[1]!);
|
|
158
|
+
const beats = pattern[2]!.trim().split(" ").map(Number);
|
|
159
|
+
const velocity = parseVelocity(pattern[4]);
|
|
160
|
+
if (
|
|
161
|
+
!voice ||
|
|
162
|
+
beats.length > MAX_PATTERN_BEATS ||
|
|
163
|
+
!beats.every(validBeat) ||
|
|
164
|
+
velocity === undefined
|
|
165
|
+
)
|
|
166
|
+
return undefined;
|
|
167
|
+
return {
|
|
168
|
+
type: "drum-hits",
|
|
169
|
+
voice,
|
|
170
|
+
beats: [...new Set(beats)].sort((a, b) => a - b),
|
|
171
|
+
velocity,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const clear = text.match(/^clear ([a-z-]+)$/);
|
|
176
|
+
if (clear) {
|
|
177
|
+
const voice = parseDrumVoice(clear[1]!);
|
|
178
|
+
if (voice) return { type: "drum-clear", voice };
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (/^(?:filter|lowpass) off$/.test(text)) return { type: "filter-off" };
|
|
183
|
+
const filter = text.match(
|
|
184
|
+
new RegExp(`^(?:filter|lowpass) ${NUMBER}(?: ${NUMBER})?$`),
|
|
185
|
+
);
|
|
186
|
+
if (filter) {
|
|
187
|
+
const cutoff = Number(filter[1]);
|
|
188
|
+
const resonance = filter[2] === undefined ? 0 : Number(filter[2]);
|
|
189
|
+
if (
|
|
190
|
+
!validCutoff(cutoff) ||
|
|
191
|
+
!Number.isFinite(resonance) ||
|
|
192
|
+
resonance > SCORE_LIMITS.maxFilterResonance
|
|
193
|
+
)
|
|
194
|
+
return undefined;
|
|
195
|
+
return { type: "filter", cutoff, resonance };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (text === "delay off") return { type: "delay-off" };
|
|
199
|
+
const delay = text.match(
|
|
200
|
+
new RegExp(`^delay ${NUMBER}(?: ${NUMBER}(?: ${NUMBER})?)?$`),
|
|
201
|
+
);
|
|
202
|
+
if (delay) {
|
|
203
|
+
const beats = Number(delay[1]);
|
|
204
|
+
const feedback = delay[2] === undefined ? 0.3 : Number(delay[2]);
|
|
205
|
+
const mix = delay[3] === undefined ? 0.35 : Number(delay[3]);
|
|
206
|
+
if (
|
|
207
|
+
!Number.isFinite(beats) ||
|
|
208
|
+
beats < SCORE_LIMITS.minDelayBeats ||
|
|
209
|
+
beats > SCORE_LIMITS.maxDelayBeats ||
|
|
210
|
+
!Number.isFinite(feedback) ||
|
|
211
|
+
feedback > SCORE_LIMITS.maxDelayFeedback ||
|
|
212
|
+
!Number.isFinite(mix) ||
|
|
213
|
+
mix > SCORE_LIMITS.maxDelayMix
|
|
214
|
+
)
|
|
215
|
+
return undefined;
|
|
216
|
+
return { type: "delay", beats, feedback, mix };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (/^reverb off$/.test(text)) return { type: "reverb-off" };
|
|
220
|
+
const reverb = text.match(new RegExp(`^reverb ${NUMBER}(?: ${NUMBER})?$`));
|
|
221
|
+
if (reverb) {
|
|
222
|
+
const mix = Number(reverb[1]);
|
|
223
|
+
const size = reverb[2] === undefined ? 0.5 : Number(reverb[2]);
|
|
224
|
+
if (
|
|
225
|
+
!Number.isFinite(mix) ||
|
|
226
|
+
mix > SCORE_LIMITS.maxReverbMix ||
|
|
227
|
+
!Number.isFinite(size) ||
|
|
228
|
+
size > SCORE_LIMITS.maxReverbSize
|
|
229
|
+
)
|
|
230
|
+
return undefined;
|
|
231
|
+
return { type: "reverb", mix, size };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const clearLane = text.match(/^(?:clear|reset) ([a-z-]+) automation$/);
|
|
235
|
+
if (clearLane) {
|
|
236
|
+
const parameter = parseEffectLane(clearLane[1]!);
|
|
237
|
+
return parameter
|
|
238
|
+
? { type: "effect-automation-clear", parameter }
|
|
239
|
+
: undefined;
|
|
240
|
+
}
|
|
241
|
+
const automate = text.match(
|
|
242
|
+
new RegExp(`^(?:automate|automation) ([a-z-]+) at ${NUMBER} ${NUMBER}$`),
|
|
243
|
+
);
|
|
244
|
+
if (automate) {
|
|
245
|
+
const parameter = parseEffectLane(automate[1]!);
|
|
246
|
+
const beat = Number(automate[2]);
|
|
247
|
+
const value = Number(automate[3]);
|
|
248
|
+
if (!parameter || !validBeat(beat)) return undefined;
|
|
249
|
+
const { min, max } = AUTOMATION_LANES[parameter];
|
|
250
|
+
if (!Number.isFinite(value) || value < min || value > max) return undefined;
|
|
251
|
+
return { type: "effect-automation", parameter, beat, value };
|
|
252
|
+
}
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Apply a parsed command to `trackId`. `newId(index)` must return a unique
|
|
258
|
+
* note id for each drum hit created by this command.
|
|
259
|
+
*/
|
|
260
|
+
export function applyMusicCommand(
|
|
261
|
+
score: TrackScore,
|
|
262
|
+
trackId: string,
|
|
263
|
+
command: MusicCommand,
|
|
264
|
+
newId: (index: number) => string,
|
|
265
|
+
): MusicResult {
|
|
266
|
+
const track = score.tracks.find((candidate) => candidate.id === trackId);
|
|
267
|
+
if (!track) return { message: `no track · ${trackId}` };
|
|
268
|
+
|
|
269
|
+
if (command.type === "solo") {
|
|
270
|
+
const next = updateTrack(score, trackId, { solo: command.solo });
|
|
271
|
+
return {
|
|
272
|
+
message: `${command.solo ? "solo" : "unsolo"} · ${trackId}`,
|
|
273
|
+
next,
|
|
274
|
+
kind: "score.track",
|
|
275
|
+
payload: { trackId, patch: { solo: command.solo } },
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
if (command.type === "filter" || command.type === "filter-off") {
|
|
279
|
+
const filter =
|
|
280
|
+
command.type === "filter"
|
|
281
|
+
? { cutoff: command.cutoff, resonance: command.resonance }
|
|
282
|
+
: null;
|
|
283
|
+
return {
|
|
284
|
+
message: filter
|
|
285
|
+
? `filter · ${filter.cutoff} Hz res ${filter.resonance}`
|
|
286
|
+
: "filter · off",
|
|
287
|
+
next: updateTrack(score, trackId, { filter }),
|
|
288
|
+
kind: "score.effect",
|
|
289
|
+
payload: { trackId, effect: "filter", value: filter },
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
if (command.type === "delay" || command.type === "delay-off") {
|
|
293
|
+
const delay =
|
|
294
|
+
command.type === "delay"
|
|
295
|
+
? { beats: command.beats, feedback: command.feedback, mix: command.mix }
|
|
296
|
+
: null;
|
|
297
|
+
return {
|
|
298
|
+
message: delay
|
|
299
|
+
? `delay · ${delay.beats} beats fb ${delay.feedback} mix ${delay.mix}`
|
|
300
|
+
: "delay · off",
|
|
301
|
+
next: updateTrack(score, trackId, { delay }),
|
|
302
|
+
kind: "score.effect",
|
|
303
|
+
payload: { trackId, effect: "delay", value: delay },
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if (command.type === "reverb" || command.type === "reverb-off") {
|
|
307
|
+
const reverb =
|
|
308
|
+
command.type === "reverb"
|
|
309
|
+
? { mix: command.mix, size: command.size }
|
|
310
|
+
: null;
|
|
311
|
+
return {
|
|
312
|
+
message: reverb
|
|
313
|
+
? `reverb · mix ${reverb.mix} size ${reverb.size}`
|
|
314
|
+
: "reverb · off",
|
|
315
|
+
next: updateTrack(score, trackId, { reverb }),
|
|
316
|
+
kind: "score.effect",
|
|
317
|
+
payload: { trackId, effect: "reverb", value: reverb },
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (
|
|
321
|
+
command.type === "effect-automation" ||
|
|
322
|
+
command.type === "effect-automation-clear"
|
|
323
|
+
) {
|
|
324
|
+
const { parameter } = command;
|
|
325
|
+
let points: { tick: number; value: number }[] = [];
|
|
326
|
+
if (command.type === "effect-automation") {
|
|
327
|
+
const tick = Math.round(command.beat * score.ticksPerBeat);
|
|
328
|
+
const merged = new Map(
|
|
329
|
+
(track[AUTOMATION_LANES[parameter].field] ?? []).map((point) => [
|
|
330
|
+
point.tick,
|
|
331
|
+
point,
|
|
332
|
+
]),
|
|
333
|
+
);
|
|
334
|
+
merged.set(tick, { tick, value: command.value });
|
|
335
|
+
points = [...merged.values()].sort((a, b) => a.tick - b.tick);
|
|
336
|
+
if (points.length > SCORE_LIMITS.maxAutomationPoints)
|
|
337
|
+
return { message: `${parameter} automation is full` };
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
message: `automation · ${parameter} ${points.length} point${points.length === 1 ? "" : "s"}`,
|
|
341
|
+
next: setTrackAutomation(score, trackId, parameter, points),
|
|
342
|
+
kind: "score.automation",
|
|
343
|
+
payload: { trackId, parameter, points },
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (!isDrumInstrument(track.instrument))
|
|
348
|
+
return { message: `${trackId} is not a drum track · try instrument kit` };
|
|
349
|
+
|
|
350
|
+
if (command.type === "drum-clear") {
|
|
351
|
+
const removed = score.notes.filter(
|
|
352
|
+
(note) =>
|
|
353
|
+
note.trackId === trackId &&
|
|
354
|
+
drumVoiceForPitch(note.pitch) === command.voice,
|
|
355
|
+
);
|
|
356
|
+
if (removed.length === 0) return { message: `no ${command.voice} hits` };
|
|
357
|
+
const next = createScore({
|
|
358
|
+
...score.toJSON(),
|
|
359
|
+
notes: score.notes.filter((note) => !removed.includes(note)),
|
|
360
|
+
});
|
|
361
|
+
return {
|
|
362
|
+
message: `cleared ${removed.length} ${command.voice} hit${removed.length === 1 ? "" : "s"}`,
|
|
363
|
+
next,
|
|
364
|
+
kind: "score.drums",
|
|
365
|
+
payload: { trackId, voice: command.voice, removed: removed.length },
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const beats =
|
|
370
|
+
command.type === "drum-hits"
|
|
371
|
+
? command.beats
|
|
372
|
+
: everyBeats(command.from, command.step, score.bars * score.beatsPerBar);
|
|
373
|
+
if (beats.length > MAX_PATTERN_HITS)
|
|
374
|
+
return { message: `pattern exceeds ${MAX_PATTERN_HITS} hits` };
|
|
375
|
+
const pitch = drumVoicePitch(command.voice);
|
|
376
|
+
const existing = new Set(
|
|
377
|
+
score.notes
|
|
378
|
+
.filter((note) => note.trackId === trackId && note.pitch === pitch)
|
|
379
|
+
.map((note) => note.startTick),
|
|
380
|
+
);
|
|
381
|
+
const durationTicks = Math.max(1, Math.round(score.ticksPerBeat / 4));
|
|
382
|
+
const hits: NoteInput[] = [];
|
|
383
|
+
for (const beat of beats) {
|
|
384
|
+
const startTick = Math.round(beat * score.ticksPerBeat);
|
|
385
|
+
if (startTick > SCORE_LIMITS.maxTick || existing.has(startTick)) continue;
|
|
386
|
+
existing.add(startTick);
|
|
387
|
+
hits.push({
|
|
388
|
+
id: newId(hits.length),
|
|
389
|
+
trackId,
|
|
390
|
+
startTick,
|
|
391
|
+
durationTicks,
|
|
392
|
+
pitch,
|
|
393
|
+
velocity: command.velocity,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
if (hits.length === 0) return { message: `${command.voice} already there` };
|
|
397
|
+
if (score.notes.length + hits.length > SCORE_LIMITS.maxNotes)
|
|
398
|
+
return { message: `score is full (${SCORE_LIMITS.maxNotes} notes)` };
|
|
399
|
+
const next = createScore({
|
|
400
|
+
...score.toJSON(),
|
|
401
|
+
notes: [...score.notes, ...hits],
|
|
402
|
+
});
|
|
403
|
+
return {
|
|
404
|
+
message: `+${hits.length} ${command.voice} hit${hits.length === 1 ? "" : "s"}`,
|
|
405
|
+
next,
|
|
406
|
+
kind: "score.drums",
|
|
407
|
+
payload: {
|
|
408
|
+
trackId,
|
|
409
|
+
voice: command.voice,
|
|
410
|
+
ticks: hits.map((hit) => hit.startTick),
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function everyBeats(from: number, step: number, loopBeats: number): number[] {
|
|
416
|
+
const beats: number[] = [];
|
|
417
|
+
// Index-based stepping avoids accumulating floating-point drift.
|
|
418
|
+
for (let index = 0; beats.length <= MAX_PATTERN_HITS; index += 1) {
|
|
419
|
+
const beat = from + index * step;
|
|
420
|
+
if (beat >= loopBeats) break;
|
|
421
|
+
beats.push(beat);
|
|
422
|
+
}
|
|
423
|
+
return beats;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function parseVelocity(value: string | undefined): number | undefined {
|
|
427
|
+
if (value === undefined) return DEFAULT_HIT_VELOCITY;
|
|
428
|
+
const velocity = Number(value);
|
|
429
|
+
return Number.isFinite(velocity) && velocity <= 1 ? velocity : undefined;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function validBeat(beat: number): boolean {
|
|
433
|
+
return (
|
|
434
|
+
Number.isFinite(beat) && beat >= 0 && beat * 4_096 <= SCORE_LIMITS.maxTick
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function validCutoff(cutoff: number): boolean {
|
|
439
|
+
return (
|
|
440
|
+
Number.isFinite(cutoff) &&
|
|
441
|
+
cutoff >= SCORE_LIMITS.minFilterCutoff &&
|
|
442
|
+
cutoff <= SCORE_LIMITS.maxFilterCutoff
|
|
443
|
+
);
|
|
444
|
+
}
|
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { runDaemon } from "./session/daemon.ts";
|
|
4
|
+
import { assertSessionId } from "./session/store.ts";
|
|
5
|
+
|
|
6
|
+
process.title = "dawgd";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* dawgd entry point. `dawg` spawns this detached when no daemon is alive:
|
|
10
|
+
* bun src/daemon.ts --workspace <dir> --session <id> [--grace-ms <ms>]
|
|
11
|
+
*/
|
|
12
|
+
function option(name: string): string | undefined {
|
|
13
|
+
const index = process.argv.indexOf(name);
|
|
14
|
+
const value = index >= 0 ? process.argv[index + 1] : undefined;
|
|
15
|
+
return value && !value.startsWith("--") ? value : undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const workspace = resolve(option("--workspace") ?? process.cwd());
|
|
19
|
+
const sessionId = option("--session");
|
|
20
|
+
assertSessionId(sessionId);
|
|
21
|
+
const graceText = option("--grace-ms");
|
|
22
|
+
const graceMs = graceText === undefined ? undefined : Number(graceText);
|
|
23
|
+
if (graceMs !== undefined && (!Number.isFinite(graceMs) || graceMs < 0)) {
|
|
24
|
+
process.stderr.write("dawgd: --grace-ms must be a non-negative number\n");
|
|
25
|
+
process.exit(2);
|
|
26
|
+
}
|
|
27
|
+
await runDaemon(
|
|
28
|
+
graceMs === undefined
|
|
29
|
+
? { workspace, sessionId }
|
|
30
|
+
: { workspace, sessionId, graceMs },
|
|
31
|
+
);
|