@wenathlan/debonair 2.0.94 → 2.0.118
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/README.md +13 -1
- package/docs/.gitkeep +0 -0
- package/docs/competitor-dissection.md +572 -0
- package/docs/enginemap.md +94 -0
- package/docs/planning.ladder.md +1107 -0
- package/docs/planning.v2.28-completo.md +69 -0
- package/docs/planning.versions.2.0.97-3.0.md +48 -0
- package/package.json +25 -1
- package/tests/.gitkeep +0 -0
- package/tests/analyze.test.ts +54 -0
- package/tests/arrangement.test.ts +109 -0
- package/tests/arranger.test.ts +116 -0
- package/tests/beatgrid.test.ts +0 -0
- package/tests/dawview.test.ts +104 -0
- package/tests/ducking.test.ts +0 -0
- package/tests/family.test.ts +80 -0
- package/tests/familyurl.test.ts +32 -0
- package/tests/fingerprint.test.ts +61 -0
- package/tests/genrematrix.test.ts +116 -0
- package/tests/harmonyengine.test.ts +204 -0
- package/tests/katexis.test.ts +97 -0
- package/tests/loudness.test.ts +0 -0
- package/tests/midiwriter.test.ts +183 -0
- package/tests/mixergraph.test.ts +0 -0
- package/tests/motionpref.test.ts +82 -0
- package/tests/musictheory.test.ts +231 -0
- package/tests/onboardingstate.test.ts +68 -0
- package/tests/pianoroll.test.ts +336 -0
- package/tests/remixplan.test.ts +0 -0
- package/tests/rhythmgrammar.test.ts +205 -0
- package/tests/solcss.test.ts +148 -0
- package/tests/spectrum.test.ts +59 -0
- package/tests/speechalign.test.ts +0 -0
- package/tests/stems.test.ts +127 -0
- package/tests/takeevents.test.ts +129 -0
- package/tests/takestore.test.ts +93 -0
- package/tests/timelinemodel.test.ts +0 -0
- package/tests/tuning.test.ts +27 -0
- package/tests/vocalsynth.test.ts +91 -0
- package/tests/wavwriter.test.ts +137 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// # pianoroll.test — the note editor of the katexis engine: every test
|
|
2
|
+
// watches the contract the studio edits under — the import is deterministic,
|
|
3
|
+
// the rebuild answers the schedule bit-for-bit (deep equal), the ids stay
|
|
4
|
+
// stable and unique, the edit ops snap, clamp and honor the role overlap
|
|
5
|
+
// policies, quantize straightens the swing by strength and the validator
|
|
6
|
+
// answers the problems the plan's own grammar never allows.
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { describe, it } from "vitest";
|
|
9
|
+
import { planTake, type TakePlan } from "../arranger.ts";
|
|
10
|
+
import {
|
|
11
|
+
addNote,
|
|
12
|
+
type EditableNote,
|
|
13
|
+
moveNote,
|
|
14
|
+
notesToSchedule,
|
|
15
|
+
PIANOROLL_CONSTANTS,
|
|
16
|
+
PianoRollError,
|
|
17
|
+
quantizeNote,
|
|
18
|
+
removeNote,
|
|
19
|
+
resizeNote,
|
|
20
|
+
scheduleToNotes,
|
|
21
|
+
validateNotes,
|
|
22
|
+
} from "../pianoroll.ts";
|
|
23
|
+
import { scheduleTake } from "../takeevents.ts";
|
|
24
|
+
|
|
25
|
+
/** the swung fixture (lofi swings 0.62 — the deep column offset the matrix carries) */
|
|
26
|
+
const SWUNG_PLAN: TakePlan = planTake({ prompt: "dusty lofi swing, warm sub", seed: "k3-swung", genreId: "lofi" });
|
|
27
|
+
/** the straight fixture (techno swings 0.5 — no column offset at all) */
|
|
28
|
+
const STRAIGHT_PLAN: TakePlan = planTake({
|
|
29
|
+
prompt: "warehouse loop, four on the floor",
|
|
30
|
+
seed: "k3-straight",
|
|
31
|
+
genreId: "techno",
|
|
32
|
+
});
|
|
33
|
+
const BAR: { fromBar: number; bars: number } = { fromBar: 0, bars: 1 };
|
|
34
|
+
|
|
35
|
+
/** builds a synthetic melodic note (the validator's fixture). */
|
|
36
|
+
function keysNote(step: number, midi: number, idSuffix = `${step}-${midi}`): EditableNote {
|
|
37
|
+
return {
|
|
38
|
+
id: `keys-0-${idSuffix}`,
|
|
39
|
+
role: "keys",
|
|
40
|
+
lane: "keys",
|
|
41
|
+
midi,
|
|
42
|
+
bar: 0,
|
|
43
|
+
step,
|
|
44
|
+
steps: 4,
|
|
45
|
+
velocity: 0.8,
|
|
46
|
+
swing: 0,
|
|
47
|
+
gate: 0.4,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("pianoroll — scheduleToNotes", () => {
|
|
52
|
+
const swung = scheduleToNotes(scheduleTake(SWUNG_PLAN), SWUNG_PLAN);
|
|
53
|
+
|
|
54
|
+
it("is deterministic for the same schedule and plan", () => {
|
|
55
|
+
const again = scheduleToNotes(scheduleTake(SWUNG_PLAN), SWUNG_PLAN);
|
|
56
|
+
assert.equal(JSON.stringify(again), JSON.stringify(swung));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("answers stable unique ids in the documented shape", () => {
|
|
60
|
+
const ids = swung.map((note) => note.id);
|
|
61
|
+
assert.equal(new Set(ids).size, ids.length, "the roll carried a duplicate id");
|
|
62
|
+
for (const id of ids) assert.match(id, /^[a-z]+-\d+-\d+-\d+$/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("maps every note inside the take with a step on the 16th grid", () => {
|
|
66
|
+
assert.ok(swung.length > 0);
|
|
67
|
+
for (const note of swung) {
|
|
68
|
+
assert.ok(note.bar >= 0 && note.bar < SWUNG_PLAN.bars);
|
|
69
|
+
assert.ok(note.step >= 0 && note.step < PIANOROLL_CONSTANTS.STEPS_PER_BAR);
|
|
70
|
+
assert.ok(note.steps >= PIANOROLL_CONSTANTS.MIN_NOTE_STEPS);
|
|
71
|
+
assert.ok(note.velocity >= 0 && note.velocity <= 1);
|
|
72
|
+
if (note.role !== "drums") {
|
|
73
|
+
assert.ok(note.midi >= PIANOROLL_CONSTANTS.NOTE_MIN && note.midi <= PIANOROLL_CONSTANTS.NOTE_MAX);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("recovers the plan's swing column exactly (the deep offset)", () => {
|
|
79
|
+
// lofi swings 0.62 → the columns at step % 4 === 2 ride a 0.48 offset
|
|
80
|
+
const swungHat = swung.find((note) => note.role === "drums" && note.lane === "hat" && note.step === 2);
|
|
81
|
+
assert.ok(swungHat, "the lofi grid carries a hat on the swung column");
|
|
82
|
+
assert.ok(Math.abs(swungHat.swing - 0.48) < 1e-9);
|
|
83
|
+
// the straight plan carries no swing anywhere
|
|
84
|
+
const straight = scheduleToNotes(scheduleTake(STRAIGHT_PLAN), STRAIGHT_PLAN);
|
|
85
|
+
for (const note of straight) assert.equal(note.swing, 0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("rides the bass two octaves up into the C2 window", () => {
|
|
89
|
+
const bass = swung.find((note) => note.role === "bass");
|
|
90
|
+
assert.ok(bass, "the trap plan voices the bass");
|
|
91
|
+
assert.ok(bass.midi >= PIANOROLL_CONSTANTS.NOTE_MIN, "the bass roll pitch left the window");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("pianoroll — notesToSchedule", () => {
|
|
96
|
+
it("rebuilds the swung schedule bit-for-bit (deep equal)", () => {
|
|
97
|
+
const schedule = scheduleTake(SWUNG_PLAN);
|
|
98
|
+
const rebuilt = notesToSchedule(scheduleToNotes(schedule, SWUNG_PLAN), SWUNG_PLAN);
|
|
99
|
+
assert.deepEqual(rebuilt, schedule);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("rebuilds the straight schedule bit-for-bit (deep equal)", () => {
|
|
103
|
+
const schedule = scheduleTake(STRAIGHT_PLAN);
|
|
104
|
+
const rebuilt = notesToSchedule(scheduleToNotes(schedule, STRAIGHT_PLAN), STRAIGHT_PLAN);
|
|
105
|
+
assert.deepEqual(rebuilt, schedule);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("is deterministic for the same notes and plan", () => {
|
|
109
|
+
const notes = scheduleToNotes(scheduleTake(SWUNG_PLAN), SWUNG_PLAN);
|
|
110
|
+
const first = JSON.stringify(notesToSchedule(notes, SWUNG_PLAN));
|
|
111
|
+
const second = JSON.stringify(notesToSchedule(notes, SWUNG_PLAN));
|
|
112
|
+
assert.equal(first, second);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("keeps the per-role counts consistent and the events time-ascending", () => {
|
|
116
|
+
const notes = scheduleToNotes(scheduleTake(SWUNG_PLAN), SWUNG_PLAN);
|
|
117
|
+
const rebuilt = notesToSchedule(notes, SWUNG_PLAN);
|
|
118
|
+
const counted = { drums: 0, bass: 0, keys: 0, lead: 0 };
|
|
119
|
+
for (const note of notes) counted[note.role] += 1;
|
|
120
|
+
assert.deepEqual(rebuilt.perRole, counted);
|
|
121
|
+
for (let index = 1; index < rebuilt.events.length; index++) {
|
|
122
|
+
assert.ok(rebuilt.events[index].time >= rebuilt.events[index - 1].time);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("answers the drums zero midi and the plan's duration", () => {
|
|
127
|
+
const notes = scheduleToNotes(scheduleTake(SWUNG_PLAN), SWUNG_PLAN);
|
|
128
|
+
const rebuilt = notesToSchedule(notes, SWUNG_PLAN);
|
|
129
|
+
for (const event of rebuilt.events) if (event.role === "drums") assert.equal(event.midi, 0);
|
|
130
|
+
assert.equal(rebuilt.duration, SWUNG_PLAN.durationSeconds);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("pianoroll — addNote", () => {
|
|
135
|
+
const notes = scheduleToNotes(scheduleTake(SWUNG_PLAN), SWUNG_PLAN);
|
|
136
|
+
|
|
137
|
+
it("adds one note with the hard velocity, one step and the documented id", () => {
|
|
138
|
+
const next = addNote(notes, { role: "drums", lane: "tom", bar: 0, step: 7 }, BAR, SWUNG_PLAN);
|
|
139
|
+
assert.equal(next.length, notes.length + 1);
|
|
140
|
+
const added = next.find((note) => note.lane === "tom");
|
|
141
|
+
assert.ok(added);
|
|
142
|
+
assert.equal(added.id, "drums-0-7-7");
|
|
143
|
+
assert.equal(added.velocity, PIANOROLL_CONSTANTS.VELOCITY_STEPS.hard);
|
|
144
|
+
assert.equal(added.steps, 1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("blocks a doubled lane slot (the drums policy) and spares the other lanes", () => {
|
|
148
|
+
assert.throws(
|
|
149
|
+
() => addNote(notes, { role: "drums", lane: "kick", bar: 0, step: 0 }, BAR, SWUNG_PLAN),
|
|
150
|
+
(error: unknown) => {
|
|
151
|
+
assert.ok(error instanceof PianoRollError);
|
|
152
|
+
assert.equal(error.code, "overlap");
|
|
153
|
+
return true;
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
const next = addNote(notes, { role: "drums", lane: "snare", bar: 0, step: 0 }, BAR, SWUNG_PLAN);
|
|
157
|
+
assert.equal(next.length, notes.length + 1);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("blocks the keys span overlap and spares the other pitches (the chord law)", () => {
|
|
161
|
+
const keys = notes.find((note) => note.role === "keys");
|
|
162
|
+
assert.ok(keys);
|
|
163
|
+
assert.throws(
|
|
164
|
+
() => addNote(notes, { role: "keys", midi: keys.midi, bar: 0, step: 4 }, BAR, SWUNG_PLAN),
|
|
165
|
+
(error: unknown) => error instanceof PianoRollError && error.code === "overlap",
|
|
166
|
+
);
|
|
167
|
+
const next = addNote(notes, { role: "keys", midi: PIANOROLL_CONSTANTS.NOTE_MAX, bar: 0, step: 0 }, BAR, SWUNG_PLAN);
|
|
168
|
+
assert.equal(next.length, notes.length + 1);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("blocks the doubled bass onset and allows the retrigger a slot later", () => {
|
|
172
|
+
const bass = notes.find((note) => note.role === "bass");
|
|
173
|
+
assert.ok(bass);
|
|
174
|
+
// the bass enters with the verse, so the window rides the note's own bar
|
|
175
|
+
const win = { fromBar: bass.bar, bars: 1 };
|
|
176
|
+
assert.throws(
|
|
177
|
+
() => addNote(notes, { role: "bass", midi: bass.midi, bar: bass.bar, step: bass.step }, win, SWUNG_PLAN),
|
|
178
|
+
(error: unknown) => error instanceof PianoRollError && error.code === "overlap",
|
|
179
|
+
);
|
|
180
|
+
const next = addNote(notes, { role: "bass", midi: bass.midi, bar: bass.bar, step: bass.step + 1 }, win, SWUNG_PLAN);
|
|
181
|
+
assert.equal(next.length, notes.length + 1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("clamps the drop into the clip window and refuses a window off the take", () => {
|
|
185
|
+
const clamped = addNote(notes, { role: "drums", lane: "tom", bar: 5, step: 20 }, BAR, SWUNG_PLAN);
|
|
186
|
+
const dropped = clamped.find((note) => note.lane === "tom");
|
|
187
|
+
assert.ok(dropped);
|
|
188
|
+
assert.equal(dropped.bar, 0);
|
|
189
|
+
assert.equal(dropped.step, PIANOROLL_CONSTANTS.STEPS_PER_BAR - 1);
|
|
190
|
+
assert.throws(
|
|
191
|
+
() =>
|
|
192
|
+
addNote(
|
|
193
|
+
notes,
|
|
194
|
+
{ role: "drums", lane: "tom", bar: 0, step: 0 },
|
|
195
|
+
{ fromBar: SWUNG_PLAN.bars, bars: 1 },
|
|
196
|
+
SWUNG_PLAN,
|
|
197
|
+
),
|
|
198
|
+
(error: unknown) => error instanceof PianoRollError && error.code === "out-of-window",
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("refuses a melodic midi off the C2..C6 window and a duplicate id", () => {
|
|
203
|
+
assert.throws(
|
|
204
|
+
() => addNote(notes, { role: "lead", midi: PIANOROLL_CONSTANTS.NOTE_MAX + 1, bar: 0, step: 0 }, BAR, SWUNG_PLAN),
|
|
205
|
+
(error: unknown) => error instanceof PianoRollError && error.code === "bad-note",
|
|
206
|
+
);
|
|
207
|
+
const keys = notes.find((note) => note.role === "keys");
|
|
208
|
+
assert.ok(keys);
|
|
209
|
+
const slot = { role: "keys" as const, midi: PIANOROLL_CONSTANTS.NOTE_MAX, bar: 0, step: 15 };
|
|
210
|
+
const seeded = addNote(notes, slot, BAR, SWUNG_PLAN);
|
|
211
|
+
assert.throws(
|
|
212
|
+
() => addNote(seeded, slot, BAR, SWUNG_PLAN),
|
|
213
|
+
(error: unknown) => error instanceof PianoRollError && error.code === "overlap",
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("pianoroll — removeNote, moveNote, resizeNote", () => {
|
|
219
|
+
const notes = scheduleToNotes(scheduleTake(SWUNG_PLAN), SWUNG_PLAN);
|
|
220
|
+
const kick = notes.find((note) => note.role === "drums" && note.lane === "kick" && note.bar === 0);
|
|
221
|
+
assert.ok(kick);
|
|
222
|
+
|
|
223
|
+
it("removes by id and refuses a second removal", () => {
|
|
224
|
+
const next = removeNote(notes, kick.id);
|
|
225
|
+
assert.equal(next.length, notes.length - 1);
|
|
226
|
+
assert.ok(!next.some((note) => note.id === kick.id));
|
|
227
|
+
assert.throws(
|
|
228
|
+
() => removeNote(next, kick.id),
|
|
229
|
+
(error: unknown) => error instanceof PianoRollError && error.code === "bad-note",
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("moves on the straight grid, keeps the id stable and leaves the others untouched", () => {
|
|
234
|
+
const next = moveNote(notes, kick.id, 2, BAR, SWUNG_PLAN);
|
|
235
|
+
const moved = next.find((note) => note.id === kick.id);
|
|
236
|
+
assert.ok(moved);
|
|
237
|
+
assert.equal(moved.step, kick.step + 2);
|
|
238
|
+
assert.equal(moved.bar, 0);
|
|
239
|
+
assert.equal(next.length, notes.length);
|
|
240
|
+
const others = next.filter((note) => note.id !== kick.id);
|
|
241
|
+
assert.deepEqual(
|
|
242
|
+
others,
|
|
243
|
+
notes.filter((note) => note.id !== kick.id),
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("clamps the move into the clip window", () => {
|
|
248
|
+
const next = moveNote(notes, kick.id, 100, BAR, SWUNG_PLAN);
|
|
249
|
+
const moved = next.find((note) => note.id === kick.id);
|
|
250
|
+
assert.ok(moved);
|
|
251
|
+
assert.equal(moved.step, PIANOROLL_CONSTANTS.STEPS_PER_BAR - kick.steps);
|
|
252
|
+
assert.equal(moved.bar, 0);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("refuses a move into a same-lane span and a move off the roll", () => {
|
|
256
|
+
const seeded = addNote(notes, { role: "drums", lane: "kick", bar: 0, step: 4 }, BAR, SWUNG_PLAN);
|
|
257
|
+
assert.throws(
|
|
258
|
+
() => moveNote(seeded, kick.id, 4, BAR, SWUNG_PLAN),
|
|
259
|
+
(error: unknown) => error instanceof PianoRollError && error.code === "overlap",
|
|
260
|
+
);
|
|
261
|
+
assert.throws(
|
|
262
|
+
() => moveNote(notes, "drums-9-9-9", 1, BAR, SWUNG_PLAN),
|
|
263
|
+
(error: unknown) => error instanceof PianoRollError && error.code === "bad-note",
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("resizes with the one-step floor, recomputes the gate and clamps at the window", () => {
|
|
268
|
+
const floored = resizeNote(notes, kick.id, 0, BAR, SWUNG_PLAN);
|
|
269
|
+
assert.equal(floored.find((note) => note.id === kick.id)?.steps, PIANOROLL_CONSTANTS.MIN_NOTE_STEPS);
|
|
270
|
+
const grown = resizeNote(notes, kick.id, 5, BAR, SWUNG_PLAN);
|
|
271
|
+
const resized = grown.find((note) => note.id === kick.id);
|
|
272
|
+
assert.ok(resized);
|
|
273
|
+
assert.equal(resized.steps, 5);
|
|
274
|
+
assert.equal(resized.gate, Number((5 * (60 / SWUNG_PLAN.bpm / 4)).toFixed(6)));
|
|
275
|
+
const tail = addNote(notes, { role: "drums", lane: "kick", bar: 0, step: 15 }, BAR, SWUNG_PLAN);
|
|
276
|
+
const clamped = resizeNote(
|
|
277
|
+
tail,
|
|
278
|
+
tail.find((note) => note.step === 15 && note.lane === "kick")?.id ?? "",
|
|
279
|
+
100,
|
|
280
|
+
BAR,
|
|
281
|
+
SWUNG_PLAN,
|
|
282
|
+
);
|
|
283
|
+
assert.equal(clamped.find((note) => note.lane === "kick" && note.step === 15)?.steps, 1);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("refuses a resize into a same-lane span", () => {
|
|
287
|
+
const seeded = addNote(notes, { role: "drums", lane: "kick", bar: 0, step: 4 }, BAR, SWUNG_PLAN);
|
|
288
|
+
assert.throws(
|
|
289
|
+
() => resizeNote(seeded, kick.id, 5, BAR, SWUNG_PLAN),
|
|
290
|
+
(error: unknown) => error instanceof PianoRollError && error.code === "overlap",
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe("pianoroll — quantizeNote", () => {
|
|
296
|
+
const notes = scheduleToNotes(scheduleTake(SWUNG_PLAN), SWUNG_PLAN);
|
|
297
|
+
const swungHat = notes.find((note) => note.role === "drums" && note.lane === "hat" && note.step === 2);
|
|
298
|
+
assert.ok(swungHat);
|
|
299
|
+
|
|
300
|
+
it("straightens the onset at strength 1 and keeps it at strength 0", () => {
|
|
301
|
+
const straight = quantizeNote(notes, swungHat.id, 1);
|
|
302
|
+
assert.equal(straight.find((note) => note.id === swungHat.id)?.swing, 0);
|
|
303
|
+
const kept = quantizeNote(notes, swungHat.id, 0);
|
|
304
|
+
assert.ok(Math.abs((kept.find((note) => note.id === swungHat.id)?.swing ?? 1) - swungHat.swing) < 1e-9);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("travels halfway at strength 0.5 and refuses strengths off the range", () => {
|
|
308
|
+
const half = quantizeNote(notes, swungHat.id, 0.5);
|
|
309
|
+
const halfSwing = half.find((note) => note.id === swungHat.id)?.swing ?? 1;
|
|
310
|
+
assert.ok(Math.abs(halfSwing - swungHat.swing * 0.5) < 1e-9);
|
|
311
|
+
assert.throws(
|
|
312
|
+
() => quantizeNote(notes, swungHat.id, 1.5),
|
|
313
|
+
(error: unknown) => error instanceof PianoRollError && error.code === "bad-note",
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe("pianoroll — validateNotes", () => {
|
|
319
|
+
it("answers no problems for the imported roll", () => {
|
|
320
|
+
for (const plan of [SWUNG_PLAN, STRAIGHT_PLAN]) {
|
|
321
|
+
const notes = scheduleToNotes(scheduleTake(plan), plan);
|
|
322
|
+
assert.deepEqual(validateNotes(notes, plan), []);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("answers the overlap, the duplicate id and the bounds it finds", () => {
|
|
327
|
+
const overlapping = [keysNote(0, 60), keysNote(2, 60, "2-60")];
|
|
328
|
+
assert.ok(validateNotes(overlapping, SWUNG_PLAN).some((problem) => problem.includes("overlap")));
|
|
329
|
+
const doubled = [keysNote(0, 60), { ...keysNote(0, 60), velocity: 0.5 }];
|
|
330
|
+
assert.ok(validateNotes(doubled, SWUNG_PLAN).some((problem) => problem.includes("twice")));
|
|
331
|
+
const offWindow = [keysNote(0, PIANOROLL_CONSTANTS.NOTE_MAX + 12, "0-120")];
|
|
332
|
+
assert.ok(validateNotes(offWindow, SWUNG_PLAN).some((problem) => problem.includes("C2..C6")));
|
|
333
|
+
const offTake = [{ ...keysNote(0, 60), bar: SWUNG_PLAN.bars + 1, id: "keys-17-0-60" }];
|
|
334
|
+
assert.ok(validateNotes(offTake, SWUNG_PLAN).some((problem) => problem.includes("outside the take")));
|
|
335
|
+
});
|
|
336
|
+
});
|
package/tests/remixplan.test.ts
CHANGED
|
File without changes
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// # rhythmgrammar.test — honest unit tests for the drum-pattern grammar
|
|
2
|
+
// (velocity glyphs, the eight family tables, swing offsets, seeded fills,
|
|
3
|
+
// accent curves and the half/double-time folding), runnable with the
|
|
4
|
+
// vitest runner:
|
|
5
|
+
// pnpm test
|
|
6
|
+
// The only randomness is the engine's own mulberry32 driven by fixed seeds;
|
|
7
|
+
// every glyph string is a constant fixture readable by eye.
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { hashSeed, mulberry32 } from "../harmonyengine.ts";
|
|
10
|
+
import {
|
|
11
|
+
accentMap,
|
|
12
|
+
applyAccents,
|
|
13
|
+
applySwing,
|
|
14
|
+
cloneGrid,
|
|
15
|
+
emptyGrid,
|
|
16
|
+
FAMILIES,
|
|
17
|
+
FAMILY_NAMES,
|
|
18
|
+
foldPattern,
|
|
19
|
+
gridFromSpec,
|
|
20
|
+
LANES,
|
|
21
|
+
type LaneName,
|
|
22
|
+
laneDensity,
|
|
23
|
+
maybeFill,
|
|
24
|
+
STEPS,
|
|
25
|
+
swingOffsets,
|
|
26
|
+
validateGrid,
|
|
27
|
+
} from "../rhythmgrammar.ts";
|
|
28
|
+
|
|
29
|
+
/** A spec where every lane not listed stays silent (the documented default). */
|
|
30
|
+
const spec = (overrides: Partial<Record<LaneName, string>>): Record<LaneName, string> =>
|
|
31
|
+
overrides as Record<LaneName, string>;
|
|
32
|
+
|
|
33
|
+
/** Sixteen zeroes, the shape of a silent lane. */
|
|
34
|
+
const ZEROS = new Array<number>(STEPS).fill(0);
|
|
35
|
+
|
|
36
|
+
describe("grid constants and parsing", () => {
|
|
37
|
+
it("documents eight lanes, eight families and sixteen steps", () => {
|
|
38
|
+
expect(LANES).toEqual(["kick", "snare", "hat", "openhat", "clap", "perc", "ride", "tom"]);
|
|
39
|
+
expect(new Set(LANES).size).toBe(8);
|
|
40
|
+
expect(FAMILY_NAMES).toEqual(["trap", "drill", "house", "dnb", "lofi", "pop", "techno", "ambient"]);
|
|
41
|
+
expect(new Set(FAMILY_NAMES).size).toBe(8);
|
|
42
|
+
expect(STEPS).toBe(16);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("parses the documented velocity glyphs", () => {
|
|
46
|
+
const grid = gridFromSpec(spec({ kick: "Xx:o------------" }));
|
|
47
|
+
expect(grid.steps).toBe(STEPS);
|
|
48
|
+
expect(grid.lanes.kick).toEqual([1, 0.85, 0.35, 0.6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("defaults missing lanes to a silent sixteen-step row", () => {
|
|
52
|
+
const grid = gridFromSpec(spec({ hat: "x---------------" }));
|
|
53
|
+
expect(grid.lanes.hat[0]).toBe(0.85);
|
|
54
|
+
for (const name of LANES) {
|
|
55
|
+
if (name === "hat") continue;
|
|
56
|
+
expect(grid.lanes[name], `lane ${name} defaults silent`).toEqual(ZEROS);
|
|
57
|
+
}
|
|
58
|
+
const empty = emptyGrid();
|
|
59
|
+
expect(empty.steps).toBe(STEPS);
|
|
60
|
+
for (const name of LANES) expect(empty.lanes[name]).toEqual(ZEROS);
|
|
61
|
+
expect(validateGrid(empty)).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("refuses malformed rows outright", () => {
|
|
65
|
+
expect(() => gridFromSpec(spec({ kick: "X--------------" }))).toThrow(/lane must hold 16 glyphs/);
|
|
66
|
+
expect(() => gridFromSpec(spec({ kick: "Xq--------------" }))).toThrow(/unknown velocity glyph: q/);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("family tables", () => {
|
|
71
|
+
it("yields a valid eight-lane grid for every family", () => {
|
|
72
|
+
for (const family of FAMILY_NAMES) {
|
|
73
|
+
const grid = FAMILIES[family];
|
|
74
|
+
expect(grid.steps).toBe(STEPS);
|
|
75
|
+
for (const name of LANES) {
|
|
76
|
+
expect(grid.lanes[name].length, `${family}/${name} spans the bar`).toBe(STEPS);
|
|
77
|
+
for (const v of grid.lanes[name])
|
|
78
|
+
expect(Number.isFinite(v) && v >= 0 && v <= 1, `${family}/${name} velocity ${v}`).toBe(true);
|
|
79
|
+
}
|
|
80
|
+
expect(validateGrid(grid), `${family} validates clean`).toEqual([]);
|
|
81
|
+
expect(laneDensity(grid, "kick") > 0, `${family} kicks`).toBe(true);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("drives house and techno four on the floor", () => {
|
|
86
|
+
for (const family of ["house", "techno"] as const)
|
|
87
|
+
expect(FAMILIES[family].lanes.kick).toEqual([1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]);
|
|
88
|
+
expect(laneDensity(FAMILIES.house, "kick")).toBe(4);
|
|
89
|
+
expect(laneDensity(emptyGrid(), "tom")).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("clones deeply so mutations never reach the tables", () => {
|
|
93
|
+
const copy = cloneGrid(FAMILIES.trap);
|
|
94
|
+
expect(copy).toEqual(FAMILIES.trap);
|
|
95
|
+
expect(copy.lanes.kick).not.toBe(FAMILIES.trap.lanes.kick);
|
|
96
|
+
copy.lanes.snare[8] = 0.5;
|
|
97
|
+
expect(FAMILIES.trap.lanes.snare[8]).toBe(0.85);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("swing", () => {
|
|
102
|
+
it("stays straight at 0.5 and clamps outside the band", () => {
|
|
103
|
+
expect(swingOffsets(0.5)).toEqual(ZEROS);
|
|
104
|
+
expect(swingOffsets(0.4)).toEqual(ZEROS);
|
|
105
|
+
expect(swingOffsets(0.8)).toEqual(ZEROS);
|
|
106
|
+
expect(swingOffsets(Number.NaN)).toEqual(ZEROS);
|
|
107
|
+
expect(swingOffsets(0.625).every(Number.isFinite)).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("shifts the offbeat 8ths by 4r - 2 on division 2", () => {
|
|
111
|
+
expect(swingOffsets(0.625, 2)).toEqual([0, 0, 0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0]);
|
|
112
|
+
expect(swingOffsets(0.75, 2)[2]).toBe(1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("shifts every odd 16th by 2r - 1 on division 4", () => {
|
|
116
|
+
const offsets = swingOffsets(0.625, 4);
|
|
117
|
+
expect(offsets.filter((_, step) => step % 2 === 1)).toEqual(new Array(8).fill(0.25));
|
|
118
|
+
expect(offsets.filter((_, step) => step % 2 === 0)).toEqual(new Array(8).fill(0));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("attaches the offsets without touching the velocities", () => {
|
|
122
|
+
const swung = applySwing(FAMILIES.house, 0.58, 2);
|
|
123
|
+
expect(swung.grid).toEqual(FAMILIES.house);
|
|
124
|
+
expect(swung.grid).not.toBe(FAMILIES.house);
|
|
125
|
+
expect(swung.offsets).toEqual(swingOffsets(0.58, 2));
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("accents", () => {
|
|
130
|
+
it("peaks every accent map on the downbeats", () => {
|
|
131
|
+
for (const family of FAMILY_NAMES) {
|
|
132
|
+
const curve = accentMap(family);
|
|
133
|
+
expect(curve.length).toBe(STEPS);
|
|
134
|
+
expect(curve[0], `${family} peaks on the one`).toBe(Math.max(...curve));
|
|
135
|
+
expect(curve.every(Number.isFinite), `${family} curve stays finite`).toBe(true);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("writes the documented per-style curves", () => {
|
|
140
|
+
expect(accentMap("house")).toEqual([
|
|
141
|
+
1.2, 0.8, 0.95, 0.8, 1.2, 0.8, 0.95, 0.8, 1.2, 0.8, 0.95, 0.8, 1.2, 0.8, 0.95, 0.8,
|
|
142
|
+
]);
|
|
143
|
+
expect(accentMap("trap")).toEqual([
|
|
144
|
+
1.2, 0.8, 0.95, 0.8, 1.1, 0.8, 0.95, 0.8, 1.2, 0.8, 0.95, 0.8, 1.1, 0.8, 0.95, 0.8,
|
|
145
|
+
]);
|
|
146
|
+
expect(accentMap("ambient")).toEqual([1.05, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("multiplies the grid by the curve and clamps to the unit band", () => {
|
|
150
|
+
const accented = applyAccents(FAMILIES.house, "house");
|
|
151
|
+
expect(accented.lanes.kick, "1.2 clamps back to 1").toEqual([1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]);
|
|
152
|
+
expect(accented.lanes.hat[2]).toBeCloseTo(0.57, 12);
|
|
153
|
+
expect(accented.lanes.snare).toEqual(ZEROS);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("seeded fills", () => {
|
|
158
|
+
const filled = (seed: string) => maybeFill(FAMILIES.trap, mulberry32(hashSeed(seed)));
|
|
159
|
+
|
|
160
|
+
it("passes the grid through untouched when the rng draws over the chance", () => {
|
|
161
|
+
const untouched = filled("alpha"); // first draw 0.500 >= 0.25
|
|
162
|
+
expect(untouched).toEqual(FAMILIES.trap);
|
|
163
|
+
expect(untouched.lanes.snare).toEqual(FAMILIES.trap.lanes.snare);
|
|
164
|
+
expect(untouched.lanes.hat).toEqual(FAMILIES.trap.lanes.hat);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("replays the same fill for the same seed", () => {
|
|
168
|
+
expect(filled("c")).toEqual(filled("c"));
|
|
169
|
+
expect(filled("l")).toEqual(filled("l"));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("cascades the snare over the last four steps and halves the hats", () => {
|
|
173
|
+
const grid = filled("l"); // second draw 0.910 -> no tom tail
|
|
174
|
+
expect(grid.lanes.snare.slice(12)).toEqual([0.85, 0.6, 0.85, 1]);
|
|
175
|
+
expect(grid.lanes.hat.slice(12)).toEqual([0.425, 0.3, 0.5, 0.5]);
|
|
176
|
+
expect(grid.lanes.tom.slice(14)).toEqual([0, 0]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("lands different fills for different seeds", () => {
|
|
180
|
+
const withTom = filled("c"); // second draw 0.372 -> tom tail fires
|
|
181
|
+
const withoutTom = filled("l");
|
|
182
|
+
expect(withTom.lanes.tom.slice(14)).toEqual([0.85, 1]);
|
|
183
|
+
expect(withTom).not.toEqual(withoutTom);
|
|
184
|
+
expect(filled("c")).not.toEqual(filled("alpha"));
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("half/double-time folding", () => {
|
|
189
|
+
const marker = gridFromSpec(spec({ kick: "X-------x-------", hat: "XxXxXxXxXxXxXxXx" }));
|
|
190
|
+
|
|
191
|
+
it("folds half-time inside one bar: even steps carry the phrase, odd steps and the back half drop", () => {
|
|
192
|
+
const folded = foldPattern(marker, 0.5);
|
|
193
|
+
expect(folded.steps).toBe(STEPS);
|
|
194
|
+
for (const name of LANES) for (let step = 1; step < STEPS; step += 2) expect(folded.lanes[name][step]).toBe(0);
|
|
195
|
+
expect(folded.lanes.kick).toEqual([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
|
196
|
+
expect(folded.lanes.kick.includes(0.85)).toBe(false);
|
|
197
|
+
expect(folded.lanes.hat).toEqual([1, 0, 0.85, 0, 1, 0, 0.85, 0, 1, 0, 0.85, 0, 1, 0, 0.85, 0]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("loops the phrase twice per bar in double time", () => {
|
|
201
|
+
const folded = foldPattern(marker, 2);
|
|
202
|
+
expect(folded.lanes.kick).toEqual([1, 0, 0, 0, 0.85, 0, 0, 0, 1, 0, 0, 0, 0.85, 0, 0, 0]);
|
|
203
|
+
expect(folded.lanes.hat).toEqual(new Array(STEPS).fill(1));
|
|
204
|
+
});
|
|
205
|
+
});
|