@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.
Files changed (40) hide show
  1. package/README.md +13 -1
  2. package/docs/.gitkeep +0 -0
  3. package/docs/competitor-dissection.md +572 -0
  4. package/docs/enginemap.md +94 -0
  5. package/docs/planning.ladder.md +1107 -0
  6. package/docs/planning.v2.28-completo.md +69 -0
  7. package/docs/planning.versions.2.0.97-3.0.md +48 -0
  8. package/package.json +25 -1
  9. package/tests/.gitkeep +0 -0
  10. package/tests/analyze.test.ts +54 -0
  11. package/tests/arrangement.test.ts +109 -0
  12. package/tests/arranger.test.ts +116 -0
  13. package/tests/beatgrid.test.ts +0 -0
  14. package/tests/dawview.test.ts +104 -0
  15. package/tests/ducking.test.ts +0 -0
  16. package/tests/family.test.ts +80 -0
  17. package/tests/familyurl.test.ts +32 -0
  18. package/tests/fingerprint.test.ts +61 -0
  19. package/tests/genrematrix.test.ts +116 -0
  20. package/tests/harmonyengine.test.ts +204 -0
  21. package/tests/katexis.test.ts +97 -0
  22. package/tests/loudness.test.ts +0 -0
  23. package/tests/midiwriter.test.ts +183 -0
  24. package/tests/mixergraph.test.ts +0 -0
  25. package/tests/motionpref.test.ts +82 -0
  26. package/tests/musictheory.test.ts +231 -0
  27. package/tests/onboardingstate.test.ts +68 -0
  28. package/tests/pianoroll.test.ts +336 -0
  29. package/tests/remixplan.test.ts +0 -0
  30. package/tests/rhythmgrammar.test.ts +205 -0
  31. package/tests/solcss.test.ts +148 -0
  32. package/tests/spectrum.test.ts +59 -0
  33. package/tests/speechalign.test.ts +0 -0
  34. package/tests/stems.test.ts +127 -0
  35. package/tests/takeevents.test.ts +129 -0
  36. package/tests/takestore.test.ts +93 -0
  37. package/tests/timelinemodel.test.ts +0 -0
  38. package/tests/tuning.test.ts +27 -0
  39. package/tests/vocalsynth.test.ts +91 -0
  40. package/tests/wavwriter.test.ts +137 -0
@@ -0,0 +1,148 @@
1
+ // # solcss.test — the theme contract of the Sol stylesheet (the reform wave):
2
+ // the trylle foundation wearing the windows hands, pinned as CI guards over
3
+ // the real file the app ships. Zero banned hues, zero pills, zero
4
+ // transition:all, zero scale(0) entries OUTSIDE the windows pin law (the
5
+ // dtPopIntro bounce is the one lawful scale-from-zero), the reduced-motion
6
+ // hard stop and the reduced-transparency floors present, the in-session
7
+ // data-motion mirror, the tabular-nums discipline, the trylle type stacks
8
+ // and the two-accent contract (the brass identity beside the windows
9
+ // accent) all verified verbatim. The stylesheet is read as text — no
10
+ // browser, no DOM.
11
+
12
+ import assert from "node:assert/strict";
13
+ import { readFileSync } from "node:fs";
14
+ import { describe, it } from "vitest";
15
+
16
+ const css = readFileSync(new URL("../Sol/sol.css", import.meta.url), "utf8");
17
+
18
+ /** Strips comments so the guards read rules, not the doctrine prose. */
19
+ function rules(source: string): string {
20
+ return source.replace(/\/\*[\s\S]*?\*\//g, "");
21
+ }
22
+
23
+ /** The stylesheet with the windows pin law lifted out (the one lawful scale(0)). */
24
+ function withoutPinLaw(source: string): string {
25
+ return rules(source).replace(/@keyframes dtPopIntro \{(?:[^{}]|\{[^}]*\})*\}/g, "");
26
+ }
27
+
28
+ describe("sol.css — the banned palette", () => {
29
+ it("never ships the banned blues or the tailwind orange", () => {
30
+ for (const banned of ["#3b82f6", "#2563eb", "#f97316"]) {
31
+ assert.ok(!rules(css).toLowerCase().includes(banned), `${banned} rides in the stylesheet`);
32
+ }
33
+ });
34
+
35
+ it("never ships the pill radius on buttons, badges, tabs or chips", () => {
36
+ assert.ok(!rules(css).includes("999px"), "a 999px pill rides in the stylesheet");
37
+ assert.ok(!rules(css).includes("rounded-full"), "rounded-full rides in the stylesheet");
38
+ // true circles stay legal: avatars, dots and indicators use 50%
39
+ assert.ok(rules(css).includes("border-radius: 50%"), "true circles use the 50% radius");
40
+ });
41
+ });
42
+
43
+ describe("sol.css — the motion law", () => {
44
+ it("never ships transition: all", () => {
45
+ assert.ok(!/transition\s*:\s*all/.test(rules(css)), "transition: all rides in the stylesheet");
46
+ });
47
+
48
+ it("never enters an element from scale(0) outside the windows pin law", () => {
49
+ assert.ok(!/scale\(0\)/.test(withoutPinLaw(css)), "a scale(0) entry rides outside dtPopIntro");
50
+ // the pin law itself: 0 → 1.125 (40%) → 0.725 (70%) → 1 (the bounce)
51
+ const pin = rules(css).match(/@keyframes dtPopIntro \{(?:[^{}]|\{[^}]*\})*\}/);
52
+ assert.ok(pin !== null, "the dtPopIntro keyframe is missing");
53
+ assert.ok(
54
+ pin[0].includes("scale(1.125)") && pin[0].includes("scale(0.725)"),
55
+ "the dtPopIntro bounce left the pin law",
56
+ );
57
+ });
58
+
59
+ it("carries the reduced-motion hard stop as the last word", () => {
60
+ assert.match(
61
+ css,
62
+ /@media \(prefers-reduced-motion: reduce\) \{\s*\*,\s*\*::before,\s*\*::after \{[^}]*animation-duration: 0\.01ms !important/,
63
+ "the universal hard stop is missing",
64
+ );
65
+ const hardStop = css.indexOf("@media (prefers-reduced-motion: reduce)");
66
+ const lastBlock = css.lastIndexOf("@media");
67
+ assert.ok(lastBlock >= hardStop, "a media block rides after the reduced-motion stop");
68
+ });
69
+
70
+ it("carries the reduced-transparency floors", () => {
71
+ const floors = css.match(/@media \(prefers-reduced-transparency: reduce\)/g) ?? [];
72
+ assert.ok(floors.length >= 1, "the transparency floors are missing");
73
+ assert.match(css, /backdrop-filter: none/, "the acrylic blur never dies under the floor");
74
+ });
75
+
76
+ it("mirrors the media kills into the in-session data-motion switch", () => {
77
+ assert.ok(css.includes('[data-motion="reduced"]'), "the settings switch has no stylesheet guards");
78
+ assert.match(
79
+ css,
80
+ /\[data-motion="reduced"\] \*,\s*\[data-motion="reduced"\] \*::before,\s*\[data-motion="reduced"\] \*::after \{[^}]*animation-duration: 0\.01ms !important/,
81
+ "the in-session hard stop is missing",
82
+ );
83
+ });
84
+ });
85
+
86
+ describe("sol.css — the trylle foundation", () => {
87
+ it("keeps the trylle canvas and the surface ladder", () => {
88
+ // the v2 foundation: the windows-11 canvas and the exact 8-step ladder
89
+ assert.ok(css.includes("--color-canvas: #202020"), "the trylle canvas left the tokens");
90
+ assert.ok(css.includes("--color-deep: #191919"), "the deep floor left the tokens");
91
+ assert.ok(css.includes("--color-s0: #242424"), "the ladder foot left the tokens");
92
+ assert.ok(css.includes("--color-s7: #494949"), "the ladder head left the tokens");
93
+ });
94
+
95
+ it("keeps the trylle type stacks (Inter Variable · Geist Mono)", () => {
96
+ assert.ok(css.includes('"Inter Variable"'), "the trylle sans left the tokens");
97
+ assert.ok(css.includes('"Geist Mono Variable"'), "the trylle mono left the tokens");
98
+ assert.ok(css.includes("font-size: 13px"), "the trylle body size left the base");
99
+ assert.ok(css.includes("--sol-track-display: -0.02em"), "the display tracking left the tokens");
100
+ assert.ok(css.includes("--sol-track-label: 0.14em"), "the label tracking left the tokens");
101
+ });
102
+
103
+ it("keeps the two-accent contract (the brass identity beside the windows accent)", () => {
104
+ assert.ok(css.includes("--accent: var(--color-signal)"), "the brass identity left the identity slot");
105
+ assert.ok(css.includes("--color-signal: #d9962e"), "the latão signal left the tokens");
106
+ assert.ok(css.includes("--color-win: #4cc2ff"), "the windows accent left the chrome slot");
107
+ assert.ok(css.includes("--color-focus: #4cc2ff"), "focus no longer answers the windows accent");
108
+ });
109
+
110
+ it("keeps the trylle shadow ladder and the fluent window float", () => {
111
+ assert.ok(css.includes("--shadow-8"), "the trylle shadow ladder left the tokens");
112
+ assert.ok(css.includes("0 96px 96px -48px"), "the doubling ladder head left the tokens");
113
+ assert.ok(
114
+ css.includes("--shadow-window: 0 32px 64px rgb(0 0 0 / 24%), 0 0 8px rgb(0 0 0 / 20%)"),
115
+ "the fluent window shadow left the tokens",
116
+ );
117
+ assert.ok(css.includes("--ease-window: cubic-bezier(0.85, 0.14, 0.14, 0.85)"), "the window curve left the tokens");
118
+ });
119
+
120
+ it("keeps the numeric discipline: the readouts ride tabular-nums", () => {
121
+ assert.ok(css.includes("font-variant-numeric: tabular-nums"), "tabular-nums left the stylesheet");
122
+ });
123
+
124
+ it("defines every custom property its own rules read (--dbn-line aliases)", () => {
125
+ for (const token of ["--dbn-line", "--dbn-line-strong"]) {
126
+ const defined = new RegExp(`${token.replace("-", "\\-")}\\s*:`).test(css);
127
+ assert.ok(defined, `${token} is read but never defined`);
128
+ }
129
+ });
130
+
131
+ it("keeps the sticky footer contract (the stage is a column, the footer glues)", () => {
132
+ assert.match(css, /\.winapp__stage \{[^}]*flex-direction: column/, "the stage is not a column");
133
+ assert.match(css, /\.meta-quad \{[^}]*margin-top: auto/, "the footer no longer glues to the floor");
134
+ });
135
+
136
+ it("keeps the windows hands on the chrome", () => {
137
+ assert.ok(css.includes("grayscale(80%)"), "the inactive window law left the stylesheet");
138
+ assert.ok(css.includes("rgb(232 17 35)"), "the fluent red close left the stylesheet");
139
+ assert.ok(css.includes("rgb(139 10 20)"), "the deep close hover left the stylesheet");
140
+ assert.ok(css.includes(".winapp__rz[data-edge="), "the eight resize handles left the stylesheet");
141
+ assert.match(css, /\.railnav__item:active \{ transform: scale\(0\.7\)/, "the pin press law left the rail");
142
+ assert.match(
143
+ css,
144
+ /\.winapp \{[^}]*animation: winappOpen var\(--dur-2\) var\(--ease-window\)/,
145
+ "the window curve left the frame",
146
+ );
147
+ });
148
+ });
@@ -0,0 +1,59 @@
1
+ // # spectrum.test — the spectral kernel: the fft answers known magnitudes,
2
+ // the hann window is unitary, the chroma folds a sine into its pitch class
3
+ // and the key correlation answers the scale it hears.
4
+ import assert from "node:assert/strict";
5
+ import { describe, it } from "vitest";
6
+ import { chromaOf, fftInPlace, frameSpectrum, keyOf } from "../spectrum.ts";
7
+
8
+ /** The KK minor profile rotated so the tonic (6.33) sits on pc 9 (A). */
9
+ const KK_MINOR_ROTATED: readonly number[] = Array.from(
10
+ { length: 12 },
11
+ (_, i) => [6.33, 2.68, 3.52, 5.38, 2.6, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17][(i - 9 + 12) % 12],
12
+ );
13
+
14
+ describe("spectrum — fftInPlace", () => {
15
+ it("recovers a pure sine's magnitude at the right bin", () => {
16
+ const n = 4096;
17
+ const bin = 100;
18
+ const re = Array.from({ length: n }, (_, i) => Math.sin((2 * Math.PI * bin * i) / n));
19
+ const im = new Array<number>(n).fill(0);
20
+ fftInPlace(re, im);
21
+ const half = n / 2;
22
+ const peak = Array.from({ length: half }, (_, b) => Math.hypot(re[b], im[b]));
23
+ const bestBin = peak.indexOf(Math.max(...peak));
24
+ assert.ok(Math.abs(bestBin - bin) <= 1, `peak bin ${bestBin} != ${bin}`);
25
+ });
26
+
27
+ it("answers zero imaginary energy for a real cosine symmetric input", () => {
28
+ const n = 512;
29
+ const re = Array.from({ length: n }, (_, i) => Math.cos((2 * Math.PI * 8 * i) / n));
30
+ const im = new Array<number>(n).fill(0);
31
+ fftInPlace(re, im);
32
+ assert.ok(Math.abs(im[8]) < 1e-9, "dc-adjacent imaginary leaked");
33
+ });
34
+ });
35
+
36
+ describe("spectrum — frameSpectrum + chroma", () => {
37
+ it("folds a 440hz sine into the A pitch class", () => {
38
+ const sine = Array.from({ length: 4096 }, (_, i) => Math.sin((2 * Math.PI * 440 * i) / 44100));
39
+ const frame = frameSpectrum(sine);
40
+ const chroma = chromaOf(frame.magnitudes, frame.binHz);
41
+ const best = chroma.indexOf(Math.max(...chroma));
42
+ assert.equal(best, 9, "440hz must fold to pc 9 (A)");
43
+ });
44
+
45
+ it("correlates a C-major chroma to the C major key", () => {
46
+ const chroma = [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88];
47
+ const key = keyOf(chroma);
48
+ assert.equal(key.tonic, 0);
49
+ assert.equal(key.mode, "major");
50
+ });
51
+
52
+ it("correlates an A-minor profile to the A minor key", () => {
53
+ // the chroma of an A-minor piece: the KK minor window starts at pc 9
54
+ const chroma = Array.from({ length: 12 }, (_, i) => KK_MINOR_ROTATED[i]);
55
+ const key = keyOf(chroma);
56
+ assert.equal(key.tonic, 9);
57
+ assert.equal(key.mode, "minor");
58
+ });
59
+ });
File without changes
@@ -0,0 +1,127 @@
1
+ // # stems.test — the stem planner contract: the canonical partition order,
2
+ // the conservation invariant (the sum of the stem events equals the schedule
3
+ // total, per role and overall), the determinism of the partition, the
4
+ // schema v1 manifest with the take identity and the mastering rate, the
5
+ // desk faders the bounce rides and the hard failures with their codes.
6
+ import assert from "node:assert/strict";
7
+ import { describe, it } from "vitest";
8
+ import { planTake } from "../arranger.ts";
9
+ import { MASTERING } from "../katexis.ts";
10
+ import {
11
+ planStems,
12
+ STEM_ROLES,
13
+ STEMS_SCHEMA_VERSION,
14
+ StemsError,
15
+ stemFile,
16
+ stemsManifest,
17
+ validateStems,
18
+ } from "../stems.ts";
19
+ import { type NoteEvent, scheduleTake, type TakeSchedule } from "../takeevents.ts";
20
+
21
+ describe("stems — planStems", () => {
22
+ const plan = planTake({ prompt: "midnight techno, dark sub bass, hypnotic arp", seed: "k4-stems" });
23
+ const schedule = scheduleTake(plan);
24
+ const stems = planStems(schedule, plan);
25
+
26
+ it("partitions the schedule one stem per role in canonical order", () => {
27
+ assert.equal(stems.length, STEM_ROLES.length);
28
+ assert.deepEqual(
29
+ stems.map((stem) => stem.role),
30
+ STEM_ROLES.map((policy) => policy.role),
31
+ );
32
+ });
33
+
34
+ it("conserves every scheduled event (the sum of the stems equals the take)", () => {
35
+ const total = stems.reduce((sum, stem) => sum + stem.events.length, 0);
36
+ assert.equal(total, schedule.events.length);
37
+ for (const stem of stems) assert.equal(stem.events.length, schedule.perRole[stem.role]);
38
+ });
39
+
40
+ it("keeps each stem time-ascending and inside its span", () => {
41
+ for (const stem of stems) {
42
+ for (let index = 1; index < stem.events.length; index++) {
43
+ assert.ok(stem.events[index].time >= stem.events[index - 1].time);
44
+ }
45
+ for (const event of stem.events) assert.ok(event.time + event.seconds <= stem.seconds + 1e-6);
46
+ }
47
+ });
48
+
49
+ it("is deterministic for the same schedule", () => {
50
+ assert.deepEqual(planStems(scheduleTake(plan), plan), stems);
51
+ });
52
+
53
+ it("names the files after the take and the role", () => {
54
+ for (const stem of stems) assert.equal(stem.file, stemFile(plan.id, stem.role));
55
+ assert.equal(stemFile("abc123", "drums"), "abc123-drums.wav");
56
+ });
57
+
58
+ it("rides the desk faders the plan opened with", () => {
59
+ for (const stem of stems) {
60
+ const strip = plan.strips.find((row) => row.name === stem.role);
61
+ assert.ok(strip);
62
+ assert.equal(stem.gainDb, strip.faderDb);
63
+ assert.ok(stem.channelkind === "mono" || stem.channelkind === "stereo");
64
+ }
65
+ });
66
+
67
+ it("refuses an empty schedule and an unknown role", () => {
68
+ const empty: TakeSchedule = { events: [], perRole: { drums: 0, bass: 0, keys: 0, lead: 0 }, duration: 1 };
69
+ assert.throws(
70
+ () => planStems(empty, plan),
71
+ (error: unknown) => error instanceof StemsError && error.code === "empty-schedule",
72
+ );
73
+ const stranger = {
74
+ lane: "guitar",
75
+ role: "guitar",
76
+ time: 0,
77
+ seconds: 0.1,
78
+ velocity: 0.5,
79
+ midi: 60,
80
+ } as unknown as NoteEvent;
81
+ assert.throws(
82
+ () => planStems({ ...schedule, events: [...schedule.events, stranger] }, plan),
83
+ (error: unknown) => error instanceof StemsError && error.code === "unknown-role",
84
+ );
85
+ });
86
+ });
87
+
88
+ describe("stems — manifest", () => {
89
+ const plan = planTake({ prompt: "smooth house, warm sub", seed: "k4-manifest" });
90
+ const schedule = scheduleTake(plan);
91
+ const stems = planStems(schedule, plan);
92
+ const manifest = stemsManifest({ plan, stems });
93
+
94
+ it("declares schema v1 with the take identity and the mastering rate", () => {
95
+ assert.equal(STEMS_SCHEMA_VERSION, "debonair.stems/1");
96
+ assert.equal(manifest.schemaVersion, "debonair.stems/1");
97
+ assert.equal(manifest.takeId, plan.id);
98
+ assert.equal(manifest.sampleRate, 48000);
99
+ assert.equal(manifest.sampleRate, MASTERING.sampleRateHz);
100
+ assert.equal(manifest.lufsTarget, plan.lufsTarget);
101
+ });
102
+
103
+ it("mirrors the partition into counted rows", () => {
104
+ assert.equal(manifest.stems.length, stems.length);
105
+ for (const row of manifest.stems) {
106
+ const stem = stems.find((candidate) => candidate.role === row.role);
107
+ assert.ok(stem);
108
+ assert.equal(row.file, stem.file);
109
+ assert.equal(row.events, stem.events.length);
110
+ assert.equal(row.seconds, stem.seconds);
111
+ }
112
+ assert.equal(Number.isNaN(Date.parse(manifest.generatedAt)), false);
113
+ });
114
+
115
+ it("validates clean and flags a robbed event, a foreign take and a shuffled order", () => {
116
+ assert.deepEqual(validateStems(schedule, plan, manifest), []);
117
+ const robbed = {
118
+ ...manifest,
119
+ stems: manifest.stems.map((row, index) => (index === 0 ? { ...row, events: row.events - 1 } : row)),
120
+ };
121
+ assert.ok(validateStems(schedule, plan, robbed).some((problem) => problem.code === "conservation"));
122
+ const foreign = { ...manifest, takeId: "other-take" };
123
+ assert.ok(validateStems(schedule, plan, foreign).some((problem) => problem.code === "bad-manifest"));
124
+ const shuffled = { ...manifest, stems: [...manifest.stems].reverse() };
125
+ assert.ok(validateStems(schedule, plan, shuffled).some((problem) => problem.code === "bad-order"));
126
+ });
127
+ });
@@ -0,0 +1,129 @@
1
+ // # takeevents.test — the event scheduler of the katexis engine: every test
2
+ // watches the contract the transport and the rack ride — the expansion is
3
+ // deterministic, the events sit inside the take and ascend in time, the
4
+ // velocities stay in range with the section energy applied, the bass locks
5
+ // to the kick steps, the lead stays in the key, the position maps an instant
6
+ // onto the 16-step grid and the window slice cuts cleanly.
7
+ import assert from "node:assert/strict";
8
+ import { describe, it } from "vitest";
9
+ import { planTake } from "../arranger.ts";
10
+ import { scalePcs } from "../musictheory.ts";
11
+ import { eventsInWindow, positionAt, scheduleTake, sectionAt, validateSchedule } from "../takeevents.ts";
12
+
13
+ describe("takeevents — scheduleTake", () => {
14
+ const plan = planTake({ prompt: "midnight techno, dark sub bass, hypnotic arp", seed: "demo-1" });
15
+ const schedule = scheduleTake(plan);
16
+
17
+ it("is deterministic for the same plan", () => {
18
+ const again = scheduleTake(plan);
19
+ assert.deepEqual(again, schedule);
20
+ });
21
+
22
+ it("answers events for every role and sorts them ascending", () => {
23
+ assert.ok(schedule.perRole.drums > 0);
24
+ assert.ok(schedule.perRole.bass > 0);
25
+ assert.ok(schedule.perRole.keys > 0);
26
+ assert.ok(schedule.perRole.lead > 0);
27
+ for (let index = 1; index < schedule.events.length; index++) {
28
+ assert.ok(schedule.events[index].time >= schedule.events[index - 1].time);
29
+ }
30
+ });
31
+
32
+ it("keeps every event inside the take with a velocity in range", () => {
33
+ assert.deepEqual(validateSchedule(schedule), []);
34
+ for (const event of schedule.events) {
35
+ assert.ok(event.time >= 0 && event.time < schedule.duration);
36
+ assert.ok(event.velocity >= 0 && event.velocity <= 1);
37
+ assert.ok(event.seconds > 0);
38
+ }
39
+ });
40
+
41
+ it("carries the drop louder than the intro (the section energy rides)", () => {
42
+ const intro = schedule.events.filter((event) => event.time < plan.sections[0].bars * plan.barSeconds);
43
+ const drop = plan.sections.find((section) => section.name === "drop");
44
+ assert.ok(drop);
45
+ const dropStart = drop.bar * plan.barSeconds;
46
+ const dropEvents = schedule.events.filter(
47
+ (event) =>
48
+ event.time >= dropStart && event.time < dropStart + drop.bars * plan.barSeconds && event.role === "drums",
49
+ );
50
+ const loudestIntro = Math.max(...intro.filter((event) => event.role === "drums").map((event) => event.velocity));
51
+ const loudestDrop = Math.max(...dropEvents.map((event) => event.velocity));
52
+ assert.ok(loudestDrop > loudestIntro);
53
+ });
54
+
55
+ it("locks the bass to the kick steps of the same bar", () => {
56
+ const eps = 1e-6;
57
+ for (let bar = 0; bar < plan.bars; bar++) {
58
+ const section = sectionAt(plan, bar);
59
+ if (!section.voices.includes("bass")) continue;
60
+ const barStart = bar * plan.barSeconds;
61
+ const stepSec = 60 / plan.bpm / 4;
62
+ const bassSteps = schedule.events
63
+ .filter(
64
+ (event) =>
65
+ event.role === "bass" && event.time >= barStart - eps && event.time < barStart + plan.barSeconds - eps,
66
+ )
67
+ .map((event) => Math.round((event.time - barStart) / stepSec));
68
+ const kickSteps = plan.grid.lanes.kick
69
+ .map((velocity, step) => (velocity > 0 ? step : -1))
70
+ .filter((step) => step >= 0);
71
+ assert.deepEqual(
72
+ [...bassSteps].sort((a, b) => a - b),
73
+ [...kickSteps].sort((a, b) => a - b),
74
+ );
75
+ }
76
+ });
77
+
78
+ it("holds one pad voicing per bar on the keys lane", () => {
79
+ const keysByBar = new Map<number, number>();
80
+ for (const event of schedule.events) {
81
+ if (event.role !== "keys") continue;
82
+ const bar = Math.floor(event.time / plan.barSeconds);
83
+ keysByBar.set(bar, (keysByBar.get(bar) ?? 0) + 1);
84
+ }
85
+ for (const [, count] of keysByBar) assert.ok(count >= 3, "a pad voicing carries at least a triad");
86
+ });
87
+
88
+ it("keeps the lead on the key's pitch classes", () => {
89
+ const allowed = new Set(scalePcs(plan.key));
90
+ for (const event of schedule.events) {
91
+ if (event.role !== "lead") continue;
92
+ assert.ok(allowed.has(event.midi % 12));
93
+ }
94
+ });
95
+
96
+ it("cuts a clean window slice", () => {
97
+ const window = eventsInWindow(schedule, 0, 1);
98
+ for (const event of window) assert.ok(event.time >= 0 && event.time < 1);
99
+ assert.equal(eventsInWindow(schedule, 0, 0).length, 0);
100
+ assert.equal(eventsInWindow(schedule, schedule.duration, schedule.duration + 5).length, 0);
101
+ });
102
+ });
103
+
104
+ describe("takeevents — positionAt", () => {
105
+ const plan = planTake({ prompt: "smooth house, warm sub", seed: "grid-1" });
106
+
107
+ it("maps the top of the take onto bar 1 step 1", () => {
108
+ assert.deepEqual(positionAt(plan, 0), { bar: 0, step: 0 });
109
+ });
110
+
111
+ it("maps a mid-take instant onto its bar and step", () => {
112
+ const stepSec = 60 / plan.bpm / 4;
113
+ const time = 9 * plan.barSeconds + 5 * stepSec;
114
+ const { bar, step } = positionAt(plan, time);
115
+ assert.equal(bar, 9);
116
+ assert.equal(step, 5);
117
+ });
118
+
119
+ it("clamps past the end onto the last bar", () => {
120
+ const { bar } = positionAt(plan, plan.durationSeconds + 30);
121
+ assert.equal(bar, plan.bars - 1);
122
+ });
123
+
124
+ it("never answers a negative position", () => {
125
+ const { bar, step } = positionAt(plan, -4);
126
+ assert.equal(bar, 0);
127
+ assert.equal(step, 0);
128
+ });
129
+ });
@@ -0,0 +1,93 @@
1
+ // # takestore.test — the session take store: the seam stays injectable (the
2
+ // tests run on a memory stub), the ids dedupe (a re-render replaces its
3
+ // earlier take), the order is newest first, broken documents answer empty
4
+ // and a removal keeps the rest.
5
+ import assert from "node:assert/strict";
6
+ import { describe, it } from "vitest";
7
+ import { planTake } from "../arranger.ts";
8
+ import {
9
+ findTakeRecord,
10
+ loadTakeRecords,
11
+ memoryStorage,
12
+ removeTakePlan,
13
+ saveTakePlan,
14
+ TAKE_SELECT_KEY,
15
+ TAKE_STORE_KEY,
16
+ } from "../takestore.ts";
17
+
18
+ describe("takestore", () => {
19
+ it("stores a take and reads it back with its timestamp", () => {
20
+ const storage = memoryStorage();
21
+ const plan = planTake({ prompt: "midnight techno", seed: "s1" });
22
+ const records = saveTakePlan(plan, storage);
23
+ assert.equal(records.length, 1);
24
+ assert.ok(records[0].savedAt.length > 0);
25
+ assert.equal(loadTakeRecords(storage).length, 1);
26
+ assert.equal(loadTakeRecords(storage)[0].plan.id, plan.id);
27
+ });
28
+
29
+ it("replaces the record of the same plan id (the seed lock dedupes)", () => {
30
+ const storage = memoryStorage();
31
+ const first = planTake({ prompt: "midnight techno", seed: "s1" });
32
+ const again = planTake({ prompt: "midnight techno", seed: "s1" });
33
+ saveTakePlan(first, storage);
34
+ const records = saveTakePlan(again, storage);
35
+ assert.equal(records.length, 1);
36
+ assert.equal(findTakeRecord(first.id, storage)?.plan.id, again.id);
37
+ });
38
+
39
+ it("orders the shelf newest first", () => {
40
+ const storage = memoryStorage();
41
+ const early = planTake({ prompt: "early techno", seed: "a" });
42
+ const late = planTake({ prompt: "late house", seed: "b" });
43
+ const afterFirst = saveTakePlan(early, storage);
44
+ // force the second record to read later than the first
45
+ const clock = Date.now();
46
+ const realNow = Date.now;
47
+ Date.now = () => clock + 5;
48
+ const afterSecond = saveTakePlan(late, storage);
49
+ Date.now = realNow;
50
+ assert.equal(afterSecond[0].plan.id, late.id);
51
+ assert.equal(afterSecond.length, 2);
52
+ assert.ok(afterFirst.length === 1);
53
+ });
54
+
55
+ it("removes one take and keeps the rest", () => {
56
+ const storage = memoryStorage();
57
+ const one = planTake({ prompt: "one techno", seed: "a" });
58
+ const two = planTake({ prompt: "two house", seed: "b" });
59
+ saveTakePlan(one, storage);
60
+ saveTakePlan(two, storage);
61
+ const records = removeTakePlan(one.id, storage);
62
+ assert.equal(records.length, 1);
63
+ assert.equal(records[0].plan.id, two.id);
64
+ assert.equal(findTakeRecord(one.id, storage), undefined);
65
+ });
66
+
67
+ it("answers empty for a broken or missing document", () => {
68
+ const storage = memoryStorage();
69
+ assert.deepEqual(loadTakeRecords(storage), []);
70
+ storage.setItem(TAKE_STORE_KEY, "{not json");
71
+ assert.deepEqual(loadTakeRecords(storage), []);
72
+ storage.setItem(TAKE_STORE_KEY, JSON.stringify({ nope: true }));
73
+ assert.deepEqual(loadTakeRecords(storage), []);
74
+ });
75
+
76
+ it("drops records without a plan id or bpm", () => {
77
+ const storage = memoryStorage();
78
+ const plan = planTake({ prompt: "keeps techno", seed: "a" });
79
+ saveTakePlan(plan, storage);
80
+ storage.setItem(
81
+ TAKE_STORE_KEY,
82
+ JSON.stringify([{ plan: { id: "x" } }, ...JSON.parse(storage.getItem(TAKE_STORE_KEY) ?? "[]")]),
83
+ );
84
+ const records = loadTakeRecords(storage);
85
+ assert.equal(records.length, 1);
86
+ assert.equal(records[0].plan.id, plan.id);
87
+ });
88
+
89
+ it("keeps the studio hand-off key beside the shelf key", () => {
90
+ assert.equal(TAKE_STORE_KEY, "debonair.takes");
91
+ assert.equal(TAKE_SELECT_KEY, "debonair.studio.take");
92
+ });
93
+ });
File without changes
@@ -0,0 +1,27 @@
1
+ // # tuning.test — the midi-to-hertz conversion the studio transport reads:
2
+ // A4 anchors the table, every semitone is the 12th root of two, and an
3
+ // octave doubles. The recipes ride this one function; the tests pin it.
4
+ import assert from "node:assert/strict";
5
+ import { describe, it } from "vitest";
6
+ import { A4_MIDI, CONCERT_PITCH, midiToHz } from "../Sol/studio/tuning.ts";
7
+
8
+ describe("tuning — midiToHz", () => {
9
+ it("anchors A4 at the concert pitch", () => {
10
+ assert.equal(A4_MIDI, 69);
11
+ assert.equal(CONCERT_PITCH, 440);
12
+ assert.equal(midiToHz(A4_MIDI), 440);
13
+ });
14
+
15
+ it("doubles every octave and halves below", () => {
16
+ assert.ok(Math.abs(midiToHz(A4_MIDI + 12) - 880) < 1e-9);
17
+ assert.ok(Math.abs(midiToHz(A4_MIDI - 12) - 220) < 1e-9);
18
+ });
19
+
20
+ it("walks the chromatic steps of the equal temperament", () => {
21
+ const semitone = 2 ** (1 / 12);
22
+ assert.ok(Math.abs(midiToHz(A4_MIDI + 0.5) - 440 * 2 ** (1 / 24)) < 1e-9); // fractional bends
23
+ assert.ok(Math.abs(midiToHz(60) - 261.6256) < 1e-4); // middle C
24
+ assert.ok(Math.abs(midiToHz(24) - 32.7032) < 1e-4); // the sub bass floor
25
+ assert.ok(Math.abs(midiToHz(A4_MIDI + 1) - 440 * semitone) < 1e-9);
26
+ });
27
+ });