@wenathlan/cadria 2.0.93 → 2.0.117

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 (41) hide show
  1. package/README.md +33 -1
  2. package/docs/.gitkeep +0 -0
  3. package/package.json +25 -2
  4. package/tests/.gitkeep +0 -0
  5. package/tests/audiodecode.test.ts +406 -0
  6. package/tests/audiofeatures.test.ts +271 -0
  7. package/tests/audiopipeline.test.ts +193 -0
  8. package/tests/audiorhythm.test.ts +172 -0
  9. package/tests/audiospectrum.test.ts +257 -0
  10. package/tests/audiostructure.test.ts +292 -0
  11. package/tests/audiotimbre.test.ts +270 -0
  12. package/tests/audiotonality.test.ts +289 -0
  13. package/tests/blendmodes.test.ts +31 -4
  14. package/tests/colorconvert.test.ts +3 -4
  15. package/tests/easecurves.test.ts +3 -4
  16. package/tests/family.test.ts +73 -0
  17. package/tests/familyurl.test.ts +46 -0
  18. package/tests/gateway.test.ts +373 -0
  19. package/tests/generation.test.ts +228 -0
  20. package/tests/imageexport.test.ts +443 -0
  21. package/tests/imageproject.test.ts +372 -0
  22. package/tests/imagerender.test.ts +291 -0
  23. package/tests/imagerenderpaint.test.ts +119 -0
  24. package/tests/imagestyles.test.ts +347 -0
  25. package/tests/masks.test.ts +3 -3
  26. package/tests/matiz.test.ts +8 -5
  27. package/tests/mediaprobe.test.ts +66 -22
  28. package/tests/onboardingstate.test.ts +73 -0
  29. package/tests/shadercomp.test.ts +120 -0
  30. package/tests/synthcomposition.test.ts +257 -0
  31. package/tests/synthmotion.test.ts +372 -0
  32. package/tests/synthpalette.test.ts +247 -0
  33. package/tests/synthtexture.test.ts +256 -0
  34. package/tests/timelineedit.test.ts +3 -6
  35. package/tests/timelineview.test.ts +248 -0
  36. package/tests/timeticks.test.ts +4 -4
  37. package/tests/transcodeplan.test.ts +19 -8
  38. package/tests/versawaseanimate.test.ts +106 -0
  39. package/tests/versawasescheduler.test.ts +96 -0
  40. package/tests/versawaseslices.test.ts +75 -0
  41. package/tests/visualize.test.ts +6 -6
@@ -0,0 +1,372 @@
1
+ // # synthmotion.test — the motion layer contract, runnable with vitest:
2
+ // pnpm vitest run tests/synthmotion.test.ts
3
+ // The tests watch the promises the module makes: the loop is beat-synced
4
+ // and canonical (500 ms at 120 bpm, clamped to 375-857 ms), keyframes land
5
+ // on the documented beat grid, strength tracks pulse confidence, easings
6
+ // are pure curves with exact endpoints (standard(0.5) inside (0.4, 0.9)),
7
+ // the timeline expands and caps deterministically, the reduced-motion
8
+ // contract stops everything but the reveal, and every answer is total
9
+ // (no NaN) and deterministic (deep-equal on repeat).
10
+ import assert from "node:assert/strict";
11
+ import { describe, it } from "vitest";
12
+ import type { AudioDescriptor, DescriptorStats } from "../audioattributes.ts";
13
+ import { fuseDescriptor } from "../audiofeatures.ts";
14
+ import { bezierprogress } from "../easecurves.ts";
15
+ import type { MotionKeyframe, MotionSpec } from "../synthmotion.ts";
16
+ import {
17
+ evaluateEase,
18
+ KEYFRAME_CAP,
19
+ MICRO_EASE,
20
+ MOTION_MAX_BPM,
21
+ MOTION_MIN_BPM,
22
+ motionReduced,
23
+ motionTimeline,
24
+ PULSE_SCALE_CEIL,
25
+ PULSE_SCALE_FLOOR,
26
+ synthMotion,
27
+ TIMELINE_EVENT_CAP,
28
+ } from "../synthmotion.ts";
29
+
30
+ /** one believable mid-tempo minor-key clip — bpm pinned at 120 → 500 ms loop. */
31
+ const baseStats: DescriptorStats = {
32
+ spectral: {
33
+ centroidMean: 2200,
34
+ centroidStd: 900,
35
+ rolloffMean: 7200,
36
+ flatnessMean: 0.12,
37
+ flatnessStd: 0.05,
38
+ fluxMean: 0.08,
39
+ fluxStd: 0.04,
40
+ bandBalance: [0.5, 0.6, 0.7, 0.6, 0.4, 0.2, 0.1],
41
+ brightnessIndex: 0.55,
42
+ },
43
+ rhythm: {
44
+ bpm: 120,
45
+ confidence: 0.82,
46
+ onsetsPerSecond: 3.4,
47
+ regularityIndex: 0.71,
48
+ swing: { ratio: 0.58, swung: true },
49
+ downbeatPeriodBeats: 4,
50
+ },
51
+ harmonic: {
52
+ key: { tonic: 9, mode: "minor", strength: 0.64 },
53
+ chromaEnergy: [0.3, 0.2, 0.5, 0.2, 0.4, 0.1, 0.2, 0.3, 0.9, 0.2, 0.3, 0.2],
54
+ harmonicChangeRate: 0.31,
55
+ dissonanceIndex: 0.22,
56
+ },
57
+ timbre: {
58
+ noisiness: 0.34,
59
+ warmth: 0.61,
60
+ dynamics: { rangeDb: 18.5, crestFactor: 8.2 },
61
+ brightness: 0.58,
62
+ slopeMean: -5.5,
63
+ zcrMean: 0.11,
64
+ },
65
+ structure: {
66
+ durationMs: 213000,
67
+ peakSectionMs: 24000,
68
+ repetitionIndex: 0.42,
69
+ narrativeShape: "arch",
70
+ sectionCount: 6,
71
+ },
72
+ };
73
+
74
+ /** fused descriptor with shallow stat overrides. */
75
+ function desc(over: Partial<DescriptorStats>): AudioDescriptor {
76
+ return fuseDescriptor({ ...baseStats, ...over } as DescriptorStats);
77
+ }
78
+
79
+ /** the block roles the mapping wave would hand over. */
80
+ const ROLES = ["sky", "figure", "ground", "accent"];
81
+
82
+ /** keyframes of one kind, in spec order. */
83
+ function ofKind(spec: MotionSpec, kind: MotionKeyframe["kind"]): MotionKeyframe[] {
84
+ return spec.keyframes.filter((kf) => kf.kind === kind);
85
+ }
86
+
87
+ /** distance from t to the nearest multiple of step. */
88
+ function nearMultiple(t: number, step: number): number {
89
+ const phase = t % step;
90
+ return Math.min(phase, step - phase);
91
+ }
92
+
93
+ /** every number reachable from a value (deep walk over spec/events). */
94
+ function deepNumbers(x: unknown, out: number[] = []): number[] {
95
+ if (typeof x === "number") out.push(x);
96
+ else if (Array.isArray(x)) for (const item of x) deepNumbers(item, out);
97
+ else if (x !== null && typeof x === "object") for (const v of Object.values(x)) deepNumbers(v, out);
98
+ return out;
99
+ }
100
+
101
+ describe("synthmotion loop clock", () => {
102
+ it("syncs the loop to one beat — 500 ms at 120 bpm", () => {
103
+ const spec = synthMotion(desc({}), ROLES);
104
+ assert.ok(Math.abs(spec.loopMs - 500) <= 2);
105
+ });
106
+ it("clamps the loop into the canonical 375-857 ms band", () => {
107
+ const fast = synthMotion(desc({ rhythm: { ...baseStats.rhythm, bpm: 300 } }), ROLES);
108
+ const slow = synthMotion(desc({ rhythm: { ...baseStats.rhythm, bpm: 40 } }), ROLES);
109
+ assert.ok(Math.abs(fast.loopMs - 60000 / MOTION_MAX_BPM) < 1e-9);
110
+ assert.ok(Math.abs(slow.loopMs - 60000 / MOTION_MIN_BPM) < 1e-9);
111
+ });
112
+ it("falls back to the tempo dim when scalar.bpm is unusable", () => {
113
+ const zeros = synthMotion({ ...desc({}), scalar: {}, vector: new Array(32).fill(0) }, ROLES);
114
+ assert.ok(Math.abs(zeros.loopMs - 60000 / MOTION_MIN_BPM) < 1e-9);
115
+ });
116
+ });
117
+
118
+ describe("synthmotion beat grid", () => {
119
+ it("places one pulse on the beat for sparse grooves, grid-tight", () => {
120
+ const spec = synthMotion(
121
+ desc({
122
+ rhythm: { ...baseStats.rhythm, onsetsPerSecond: 0.5 },
123
+ structure: { ...baseStats.structure, narrativeShape: "flat" },
124
+ }),
125
+ ROLES,
126
+ );
127
+ const pulses = ofKind(spec, "pulse");
128
+ assert.equal(pulses.length, 1);
129
+ assert.ok(Math.abs(pulses[0].tMs) < 0.001); // the downbeat of the loop
130
+ for (const kf of spec.keyframes) assert.ok(nearMultiple(kf.tMs, spec.loopMs) < 0.001);
131
+ });
132
+ it("subdivides to 8ths and 16ths as onset density climbs", () => {
133
+ const eighths = synthMotion(desc({ rhythm: { ...baseStats.rhythm, onsetsPerSecond: 2.5 } }), ROLES);
134
+ assert.equal(ofKind(eighths, "pulse").length, 2);
135
+ const sixteenths = synthMotion(desc({ rhythm: { ...baseStats.rhythm, onsetsPerSecond: 6 } }), ROLES);
136
+ assert.equal(ofKind(sixteenths, "pulse").length, 4);
137
+ for (const kf of sixteenths.keyframes) assert.ok(nearMultiple(kf.tMs, sixteenths.loopMs / 4) < 1e-6);
138
+ });
139
+ it("drops back to the downbeat only when the pulse grid is untrusted", () => {
140
+ const spec = synthMotion(desc({ rhythm: { ...baseStats.rhythm, confidence: 0.2, onsetsPerSecond: 6 } }), ROLES);
141
+ assert.equal(ofKind(spec, "pulse").length, 1);
142
+ });
143
+ it("scales pulse strength with pulse confidence alone", () => {
144
+ const trusted = synthMotion(desc({ rhythm: { ...baseStats.rhythm, confidence: 0.9 } }), ROLES);
145
+ const doubted = synthMotion(desc({ rhythm: { ...baseStats.rhythm, confidence: 0.2 } }), ROLES);
146
+ const hi = ofKind(trusted, "pulse").find((kf) => kf.tMs < 0.001);
147
+ const lo = ofKind(doubted, "pulse").find((kf) => kf.tMs < 0.001);
148
+ assert.ok(hi && lo && hi.strength > lo.strength);
149
+ });
150
+ it("strength also rides groove regularity", () => {
151
+ const tight = synthMotion(desc({ rhythm: { ...baseStats.rhythm, regularityIndex: 0.95 } }), ROLES);
152
+ const loose = synthMotion(desc({ rhythm: { ...baseStats.rhythm, regularityIndex: 0.05 } }), ROLES);
153
+ assert.ok(ofKind(tight, "pulse")[0].strength > ofKind(loose, "pulse")[0].strength);
154
+ });
155
+ it("reveals each loop opening with strength from the structure dims", () => {
156
+ const loud = synthMotion(
157
+ desc({ structure: { ...baseStats.structure, peakSectionMs: 60000, sectionCount: 12 } }),
158
+ ROLES,
159
+ );
160
+ const quiet = synthMotion(
161
+ desc({ structure: { ...baseStats.structure, peakSectionMs: 6000, sectionCount: 2 } }),
162
+ ROLES,
163
+ );
164
+ const a = ofKind(loud, "reveal")[0];
165
+ const b = ofKind(quiet, "reveal")[0];
166
+ assert.equal(a.tMs, 0);
167
+ assert.ok(a.strength > 0.5);
168
+ assert.ok(a.strength > b.strength);
169
+ });
170
+ it("wipes only when spectral flux runs hot", () => {
171
+ const hot = synthMotion(desc({ spectral: { ...baseStats.spectral, fluxMean: 0.3, fluxStd: 0.2 } }), ROLES);
172
+ const cold = synthMotion(desc({ spectral: { ...baseStats.spectral, fluxMean: 0.01, fluxStd: 0.01 } }), ROLES);
173
+ const wipes = ofKind(hot, "wipe");
174
+ assert.equal(wipes.length, 1);
175
+ assert.ok(Math.abs(wipes[0].tMs - hot.loopMs * 0.75) < 1e-6);
176
+ assert.equal(ofKind(cold, "wipe").length, 0);
177
+ });
178
+ it("shifts only when the narrative contour moves", () => {
179
+ const still = synthMotion(desc({ structure: { ...baseStats.structure, narrativeShape: "flat" } }), ROLES);
180
+ const arch = synthMotion(desc({}), ROLES);
181
+ assert.equal(ofKind(still, "shift").length, 0);
182
+ const shifts = ofKind(arch, "shift");
183
+ assert.equal(shifts.length, 1);
184
+ assert.ok(Math.abs(shifts[0].tMs - arch.loopMs / 2) < 1e-6);
185
+ assert.equal(arch.easing, "standard"); // contour ≥ 0.625 picks the workhorse
186
+ });
187
+ it("keeps the keyframe budget at or under 64", () => {
188
+ const busy = synthMotion(
189
+ desc({
190
+ rhythm: { ...baseStats.rhythm, onsetsPerSecond: 8 },
191
+ spectral: { ...baseStats.spectral, fluxMean: 0.3, fluxStd: 0.2 },
192
+ structure: { ...baseStats.structure, narrativeShape: "rise" },
193
+ }),
194
+ ROLES,
195
+ );
196
+ assert.ok(busy.keyframes.length <= KEYFRAME_CAP);
197
+ assert.ok(ofKind(busy, "reveal").length >= 1);
198
+ });
199
+ });
200
+
201
+ describe("synthmotion easing", () => {
202
+ const names = ["standard", "decelerate", "accelerate", "springy", "micro"] as const;
203
+ it("pins the endpoints exactly at 0 and 1 and clamps outside", () => {
204
+ for (const name of names) {
205
+ assert.equal(evaluateEase(name, 0), 0);
206
+ assert.equal(evaluateEase(name, 1), 1);
207
+ assert.equal(evaluateEase(name, -0.5), 0);
208
+ assert.equal(evaluateEase(name, 1.5), 1);
209
+ }
210
+ });
211
+ it("plays the beziers monotonic and springy bounded-bouncy", () => {
212
+ for (const name of ["standard", "decelerate", "accelerate", "micro"] as const) {
213
+ let prev = -1;
214
+ for (let i = 0; i <= 32; i += 1) {
215
+ const v = evaluateEase(name, i / 32);
216
+ assert.ok(v >= prev - 1e-9, `${name} must not decrease`);
217
+ prev = v;
218
+ }
219
+ }
220
+ let min = Number.POSITIVE_INFINITY;
221
+ let max = Number.NEGATIVE_INFINITY;
222
+ for (let i = 0; i <= 200; i += 1) {
223
+ const v = evaluateEase("springy", i / 200);
224
+ min = Math.min(min, v);
225
+ max = Math.max(max, v);
226
+ }
227
+ assert.ok(min >= 0 && min < 0.02); // leaves rest, never dips low
228
+ assert.ok(max > 1.005 && max <= 1.15); // the documented ~9.5 % overshoot
229
+ });
230
+ it("answers standard(0.5) inside (0.4, 0.9) and mirrors the house micro bezier", () => {
231
+ const mid = evaluateEase("standard", 0.5);
232
+ assert.ok(mid > 0.4 && mid < 0.9);
233
+ assert.equal(
234
+ evaluateEase("micro", 0.5),
235
+ bezierprogress(MICRO_EASE[0], MICRO_EASE[1], MICRO_EASE[2], MICRO_EASE[3], 0.5),
236
+ );
237
+ assert.equal(evaluateEase("nope" as (typeof names)[number], 0.5), mid); // unknown → standard
238
+ });
239
+ it("springy trends upward across the whole curve", () => {
240
+ const mean = (xs: number[]) => xs.reduce((sum, v) => sum + v, 0) / xs.length;
241
+ const first = [0, 1, 2].map((i) => evaluateEase("springy", (i + 0.5) / 30));
242
+ const last = [27, 28, 29].map((i) => evaluateEase("springy", (i + 0.5) / 30));
243
+ assert.ok(mean(last) > mean(first));
244
+ });
245
+ });
246
+
247
+ describe("synthmotion timeline", () => {
248
+ it("expands keyframes across loops with absolute times", () => {
249
+ const spec = synthMotion(desc({}), ROLES); // 4 keyframes per loop
250
+ const events = motionTimeline(spec, spec.loopMs * 10);
251
+ assert.equal(events.length, spec.keyframes.length * 10);
252
+ for (let i = 1; i < events.length; i += 1) assert.ok(events[i].tMs >= events[i - 1].tMs);
253
+ assert.equal(events[0].loop, 0);
254
+ assert.equal(events[0].tMs, 0);
255
+ const last = events[events.length - 1];
256
+ assert.equal(last.loop, 9);
257
+ assert.ok(last.tMs < spec.loopMs * 10);
258
+ const at = events.findIndex((event) => event.loop === 5 && event.kind === "pulse");
259
+ assert.ok(at >= 0 && events[at].tMs >= spec.loopMs * 5);
260
+ });
261
+ it("caps the expansion at 512 events, all inside the duration", () => {
262
+ const spec = synthMotion(desc({}), ROLES);
263
+ const events = motionTimeline(spec, spec.loopMs * 200);
264
+ assert.equal(events.length, TIMELINE_EVENT_CAP);
265
+ assert.ok(events.every((event) => event.tMs >= 0 && event.tMs < spec.loopMs * 200));
266
+ });
267
+ it("answers an empty timeline for zero durations and empty specs", () => {
268
+ const spec = synthMotion(desc({}), ROLES);
269
+ assert.equal(motionTimeline(spec, 0).length, 0);
270
+ assert.equal(motionTimeline(spec, -5).length, 0);
271
+ assert.equal(motionTimeline({ ...spec, keyframes: [] }, 5000).length, 0);
272
+ });
273
+ });
274
+
275
+ describe("synthmotion reduced motion", () => {
276
+ it("hard-stops drift, breath and parallax, keeping only reveals", () => {
277
+ const spec = synthMotion(desc({}), ROLES);
278
+ const reduced = motionReduced(spec);
279
+ assert.deepEqual(reduced.drift, { x: 0, y: 0, rotation: 0 });
280
+ assert.equal(reduced.pulseScale, 1);
281
+ assert.equal(reduced.parallaxDepth, 0);
282
+ assert.equal(reduced.easing, "decelerate");
283
+ assert.ok(reduced.keyframes.length >= 1);
284
+ assert.ok(reduced.keyframes.every((kf) => kf.kind === "reveal"));
285
+ const source = ofKind(spec, "reveal")[0];
286
+ assert.equal(reduced.keyframes[0].tMs, source.tMs);
287
+ assert.equal(reduced.keyframes[0].strength, source.strength);
288
+ assert.ok(ofKind(spec, "pulse").length >= 1); // the input stays untouched
289
+ });
290
+ it("is idempotent and totals over damaged specs", () => {
291
+ const once = motionReduced(synthMotion(desc({}), ROLES));
292
+ assert.deepEqual(motionReduced(once), once);
293
+ const empty = motionReduced({} as MotionSpec);
294
+ assert.equal(empty.loopMs, 500);
295
+ assert.equal(empty.keyframes.length, 0);
296
+ assert.deepEqual(empty.drift, { x: 0, y: 0, rotation: 0 });
297
+ });
298
+ });
299
+
300
+ describe("synthmotion contract", () => {
301
+ it("is deterministic — same descriptor and roles, deep-equal frozen spec", () => {
302
+ const d = desc({});
303
+ const a = synthMotion(d, ROLES);
304
+ const b = synthMotion(d, ROLES);
305
+ assert.deepEqual(a, b);
306
+ assert.ok(Object.isFrozen(a));
307
+ assert.ok(Object.isFrozen(a.keyframes));
308
+ assert.ok(Object.isFrozen(a.keyframes[0]));
309
+ assert.ok(Object.isFrozen(a.drift));
310
+ });
311
+ it("keeps parallaxDepth in [0, 1] and tracks the energyDrive dim", () => {
312
+ const driven = synthMotion(
313
+ desc({
314
+ spectral: { ...baseStats.spectral, fluxMean: 0.25 },
315
+ rhythm: { ...baseStats.rhythm, onsetsPerSecond: 7 },
316
+ }),
317
+ ROLES,
318
+ );
319
+ const idle = synthMotion(
320
+ desc({
321
+ spectral: { ...baseStats.spectral, fluxMean: 0.005 },
322
+ rhythm: { ...baseStats.rhythm, onsetsPerSecond: 0.3 },
323
+ }),
324
+ ROLES,
325
+ );
326
+ for (const spec of [driven, idle]) assert.ok(spec.parallaxDepth >= 0 && spec.parallaxDepth <= 1);
327
+ assert.ok(driven.parallaxDepth > idle.parallaxDepth);
328
+ });
329
+ it("keeps the beat breath inside the documented floors", () => {
330
+ const spec = synthMotion(desc({}), ROLES);
331
+ assert.ok(spec.pulseScale >= PULSE_SCALE_FLOOR && spec.pulseScale <= PULSE_SCALE_CEIL);
332
+ const dead = synthMotion({ ...desc({}), vector: new Array(32).fill(0) }, ROLES);
333
+ assert.equal(dead.pulseScale, PULSE_SCALE_FLOOR);
334
+ });
335
+ it("targets only provided roles, or nothing when roles are absent", () => {
336
+ const withRoles = synthMotion(desc({ spectral: { ...baseStats.spectral, fluxMean: 0.3, fluxStd: 0.2 } }), ROLES);
337
+ for (const kf of withRoles.keyframes) {
338
+ assert.ok(kf.targets.length >= 1);
339
+ for (const target of kf.targets) assert.ok(ROLES.includes(target));
340
+ }
341
+ assert.equal(ofKind(withRoles, "wipe")[0].targets.length, 1); // the seed-rotated band
342
+ const withoutRoles = synthMotion(desc({}));
343
+ for (const kf of withoutRoles.keyframes) assert.deepEqual(kf.targets, []);
344
+ });
345
+ it("never emits NaN anywhere in the spec or its timeline", () => {
346
+ const spec = synthMotion(desc({}), ROLES);
347
+ const events = motionTimeline(spec, spec.loopMs * 20);
348
+ for (const value of [...deepNumbers(spec), ...deepNumbers(events)]) assert.ok(Number.isFinite(value));
349
+ });
350
+ it("totals over a damaged descriptor and a missing one", () => {
351
+ const damaged = {
352
+ version: 1,
353
+ seed: "zzzz",
354
+ vector: new Array(32).fill(NaN),
355
+ scalar: { bpm: "fast" },
356
+ } as unknown as AudioDescriptor;
357
+ const spec = synthMotion(damaged, ROLES);
358
+ assert.ok(spec.loopMs >= 375 && spec.loopMs <= 857.15);
359
+ assert.ok(spec.keyframes.length >= 1);
360
+ for (const value of deepNumbers(spec)) assert.ok(Number.isFinite(value));
361
+ assert.deepEqual(spec.drift, { x: 0, y: 0, rotation: 0 }); // flat contour rests
362
+ assert.ok(Number.isFinite(synthMotion(null as unknown as AudioDescriptor, ROLES).loopMs));
363
+ });
364
+ it("maps the narrative contour onto the documented drift table", () => {
365
+ const flat = synthMotion(desc({ structure: { ...baseStats.structure, narrativeShape: "flat" } }), ROLES);
366
+ const rise = synthMotion(desc({ structure: { ...baseStats.structure, narrativeShape: "rise" } }), ROLES);
367
+ const fall = synthMotion(desc({ structure: { ...baseStats.structure, narrativeShape: "fall" } }), ROLES);
368
+ assert.deepEqual(flat.drift, { x: 0, y: 0, rotation: 0 });
369
+ assert.deepEqual(rise.drift, { x: 12, y: -9, rotation: 1.5 });
370
+ assert.deepEqual(fall.drift, { x: -8, y: 6, rotation: -0.8 });
371
+ });
372
+ });
@@ -0,0 +1,247 @@
1
+ // # synthpalette.test — the palette layer's contract, runnable with vitest:
2
+ // pnpm vitest run tests/synthpalette.test.ts
3
+ // Every test watches the three promises the module makes: the descriptor's
4
+ // dims drive the documented dials (tonic hue table, brightness lightness
5
+ // ladder, noisiness wash, energy accent choir), the ink/surface pair always
6
+ // clears wcag AA through the guard's rung ladder, and the same descriptor
7
+ // always answers the same palette bit-for-bit — damaged descriptors answer
8
+ // the neutral gray-green fallback rung, never NaN, never a throw.
9
+ import assert from "node:assert/strict";
10
+ import { describe, it } from "vitest";
11
+ import type { AudioDescriptor } from "../audioattributes.ts";
12
+ import { DIM_ORDER } from "../audiofeatures.ts";
13
+ import {
14
+ AA_MIN_CONTRAST,
15
+ contrastRatio,
16
+ FALLBACK_PALETTE,
17
+ hueDistance,
18
+ oklchToSrgbHex,
19
+ type Palette,
20
+ paletteVariants,
21
+ relativeLuminance,
22
+ srgbHexToOklch,
23
+ synthPalette,
24
+ TONIC_HUE_OFFSET,
25
+ tonicHue,
26
+ wrapHue,
27
+ } from "../synthpalette.ts";
28
+
29
+ // seeded LCG (the audio-test voice) — deterministic "random-ish" descriptors.
30
+ function lcg(seed: number): () => number {
31
+ let s = seed >>> 0;
32
+ return () => {
33
+ s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
34
+ return s / 0x100000000;
35
+ };
36
+ }
37
+
38
+ // base descriptor: c major, mid bright, mid energy; overrides by dim index.
39
+ function descriptor(overrides: Record<number, number> = {}): AudioDescriptor {
40
+ const vector = new Array<number>(32).fill(0);
41
+ vector[6] = 0.5;
42
+ vector[13] = 0.8;
43
+ vector[18] = 0.5;
44
+ vector[22] = 0.4;
45
+ vector[29] = 0.4;
46
+ for (const key of Object.keys(overrides)) vector[Number(key)] = overrides[Number(key)];
47
+ return { version: 1, seed: "0".repeat(16), vector, scalar: {} };
48
+ }
49
+
50
+ const HEX_SHAPE = /^#[0-9a-f]{6}$/;
51
+ const allHexes = (p: Palette): string[] => [p.anchor, p.ink, p.surface, ...p.support, ...p.accents];
52
+
53
+ /** the readable hex: asserts the parse instead of asserting away the type —
54
+ * a failing convert is a palette bug, never a silence. */
55
+ function oklchOf(hex: string) {
56
+ const value = srgbHexToOklch(hex);
57
+ assert.ok(value, `the palette shipped an unreadable hex: ${hex}`);
58
+ return value;
59
+ }
60
+
61
+ describe("synthpalette", () => {
62
+ it("maps the oklch poles and a mid gray to exact hexes", () => {
63
+ assert.equal(oklchToSrgbHex(1, 0, 0), "#ffffff");
64
+ assert.equal(oklchToSrgbHex(0, 0, 200), "#000000");
65
+ assert.equal(oklchToSrgbHex(0.59987, 0, 90), "#808080");
66
+ const gray = oklchOf("#808080");
67
+ assert.ok(Math.abs(gray.l - 0.59987) < 0.001);
68
+ assert.ok(gray.c < 0.001);
69
+ });
70
+
71
+ it("renders the canonical srgb red from its oklch coordinates", () => {
72
+ assert.equal(oklchToSrgbHex(0.627955, 0.257683, 29.2338), "#ff0000");
73
+ const red = oklchOf("#ff0000");
74
+ assert.ok(Math.abs(red.l - 0.627955) < 0.001);
75
+ assert.ok(Math.abs(red.c - 0.257683) < 0.001);
76
+ assert.ok(Math.abs(red.h - 29.2339) < 0.01);
77
+ });
78
+
79
+ it("round trips palette-grade colors through both directions", () => {
80
+ for (const [l, c, h] of [
81
+ [0.6, 0.1, 200],
82
+ [0.75, 0.08, 320],
83
+ [0.55, 0.14, 140],
84
+ [0.628, 0.2577, 29.23],
85
+ ]) {
86
+ const hex = oklchToSrgbHex(l, c, h);
87
+ assert.match(hex, HEX_SHAPE);
88
+ const back = oklchOf(hex);
89
+ assert.ok(Math.abs(back.l - l) < 0.01, `l drift for ${hex}`);
90
+ assert.ok(Math.abs(back.c - c) < 0.01, `c drift for ${hex}`);
91
+ assert.ok(hueDistance(back.h, h) < 2, `hue drift for ${hex}`);
92
+ }
93
+ assert.equal(srgbHexToOklch("nope"), null);
94
+ });
95
+
96
+ it("wraps and distances hues safely", () => {
97
+ assert.equal(wrapHue(370), 10);
98
+ assert.equal(wrapHue(-10), 350);
99
+ assert.equal(wrapHue(720), 0);
100
+ assert.equal(wrapHue(Number.NaN), 0);
101
+ assert.equal(wrapHue(Number.POSITIVE_INFINITY), 0);
102
+ assert.equal(hueDistance(10, 350), 20);
103
+ assert.equal(hueDistance(5, 5), 0);
104
+ assert.equal(hueDistance(0, 180), 180);
105
+ });
106
+
107
+ it("keeps every pitch class on its own documented hue", () => {
108
+ assert.equal(TONIC_HUE_OFFSET.length, 12);
109
+ assert.equal(tonicHue(0), 6);
110
+ const hues = Array.from({ length: 12 }, (_, t) => tonicHue(t));
111
+ for (let a = 0; a < 12; a += 1) {
112
+ for (let b = a + 1; b < 12; b += 1) {
113
+ assert.ok(hueDistance(hues[a], hues[b]) >= 12, `tonic ${a} vs ${b} collide`);
114
+ }
115
+ }
116
+ });
117
+
118
+ it("maps tonic dim 12 onto separated anchor hues (c vs f#)", () => {
119
+ const c = synthPalette(descriptor({ 12: 0 }));
120
+ const fs = synthPalette(descriptor({ 12: 6 / 11 }));
121
+ assert.equal(c.oklch.anchor.h, tonicHue(0));
122
+ assert.equal(fs.oklch.anchor.h, tonicHue(6));
123
+ assert.ok(hueDistance(c.oklch.anchor.h, fs.oklch.anchor.h) >= 20);
124
+ });
125
+
126
+ it("deepens the anchor for minor keys (mood shadow d31)", () => {
127
+ const major = synthPalette(descriptor());
128
+ const minor = synthPalette(descriptor({ 31: 0.72 }));
129
+ assert.ok(minor.oklch.anchor.c < major.oklch.anchor.c - 0.02, "minor lowers chroma");
130
+ assert.ok(minor.oklch.anchor.l < major.oklch.anchor.l - 0.02, "minor lowers lightness");
131
+ });
132
+
133
+ it("climbs the lightness ladder with the brightness dims (d6 + d18)", () => {
134
+ const dark = synthPalette(descriptor({ 6: 0.1, 18: 0.1 }));
135
+ const bright = synthPalette(descriptor({ 6: 0.9, 18: 0.9 }));
136
+ assert.ok(bright.oklch.anchor.l > dark.oklch.anchor.l + 0.2, "anchor rung rises");
137
+ const darkSurface = oklchOf(dark.surface).l;
138
+ const brightSurface = oklchOf(bright.surface).l;
139
+ assert.ok(brightSurface > darkSurface + 0.3, "surface rides to the poles");
140
+ });
141
+
142
+ it("washes the support field out with noisiness (d16)", () => {
143
+ const clean = synthPalette(descriptor({ 16: 0 }));
144
+ const noisy = synthPalette(descriptor({ 16: 1 }));
145
+ const meanC = (p: Palette) =>
146
+ p.support.reduce((sum, hex) => sum + (srgbHexToOklch(hex)?.c ?? 0), 0) / p.support.length;
147
+ assert.ok(meanC(noisy) < meanC(clean) - 0.02, `support wash ${meanC(noisy)} vs ${meanC(clean)}`);
148
+ });
149
+
150
+ it("scales the accent choir with the energy gauges (d22 + d29)", () => {
151
+ assert.equal(synthPalette(descriptor({ 22: 0, 29: 0 })).accents.length, 2);
152
+ assert.equal(synthPalette(descriptor({ 22: 0.5, 29: 0.5 })).accents.length, 3);
153
+ assert.equal(synthPalette(descriptor({ 22: 1, 29: 1 })).accents.length, 4);
154
+ assert.equal(synthPalette(descriptor()).accents.length, 3);
155
+ });
156
+
157
+ it("saturates the accents with key strength (d13)", () => {
158
+ const weak = synthPalette(descriptor({ 13: 0 }));
159
+ const strong = synthPalette(descriptor({ 13: 1 }));
160
+ const weakC = oklchOf(weak.accents[0]).c;
161
+ const strongC = oklchOf(strong.accents[0]).c;
162
+ assert.ok(strongC > weakC + 0.05, `accent chroma ${strongC} vs ${weakC}`);
163
+ });
164
+
165
+ it("guards AA ink over 20 deterministic descriptors", () => {
166
+ const next = lcg(0x9e3779b9);
167
+ for (let i = 0; i < 20; i += 1) {
168
+ const key = next();
169
+ const p = synthPalette(
170
+ descriptor({
171
+ 6: next(),
172
+ 12: next(),
173
+ 13: key,
174
+ 16: next(),
175
+ 18: next(),
176
+ 22: next(),
177
+ 29: next(),
178
+ 31: key * next(),
179
+ }),
180
+ );
181
+ assert.ok(contrastRatio(p.ink, p.surface) >= AA_MIN_CONTRAST, `case ${i}: ${p.ink} on ${p.surface}`);
182
+ for (const hex of allHexes(p)) assert.match(hex, HEX_SHAPE);
183
+ assert.equal(p.support.length, 2);
184
+ assert.ok(p.accents.length >= 2 && p.accents.length <= 4);
185
+ for (const t of [p.oklch.anchor, ...p.oklch.accents]) {
186
+ assert.ok(Number.isFinite(t.l) && Number.isFinite(t.c) && Number.isFinite(t.h));
187
+ }
188
+ }
189
+ });
190
+
191
+ it("derives light/dark/duotone variants on the anchor hue", () => {
192
+ const base = synthPalette(descriptor({ 12: 5 / 11, 13: 1, 22: 0.9, 29: 0.9 }));
193
+ const variants = paletteVariants(base);
194
+ assert.equal(variants.length, 3);
195
+ for (const v of variants) {
196
+ assert.ok(hueDistance(v.oklch.anchor.h, base.oklch.anchor.h) <= 2, "anchor hue preserved");
197
+ assert.ok(contrastRatio(v.ink, v.surface) >= AA_MIN_CONTRAST, "variant AA");
198
+ }
199
+ assert.ok(oklchOf(variants[0].surface).l > 0.9, "light variant is a gallery wall");
200
+ assert.ok(oklchOf(variants[1].surface).l < 0.3, "dark variant is a screening room");
201
+ const complement = wrapHue(base.oklch.anchor.h + 180);
202
+ assert.equal(variants[2].oklch.accents[0].h, complement, "duotone leads with the complement");
203
+ assert.equal(paletteVariants(base, 1).length, 1);
204
+ assert.equal(paletteVariants(base, 0).length, 0);
205
+ assert.equal(paletteVariants(base, 99).length, 3);
206
+ assert.equal(paletteVariants(base, Number.NaN).length, 3);
207
+ });
208
+
209
+ it("answers the gray-green fallback for damaged descriptors", () => {
210
+ assert.deepEqual(synthPalette(descriptor({ 7: Number.NaN })), FALLBACK_PALETTE);
211
+ assert.deepEqual(synthPalette({ version: 1, seed: "", vector: [0, 1, 2], scalar: {} }), FALLBACK_PALETTE);
212
+ assert.deepEqual(synthPalette(null as unknown as AudioDescriptor), FALLBACK_PALETTE);
213
+ for (const hex of allHexes(FALLBACK_PALETTE)) assert.match(hex, HEX_SHAPE);
214
+ assert.ok(contrastRatio(FALLBACK_PALETTE.ink, FALLBACK_PALETTE.surface) >= AA_MIN_CONTRAST);
215
+ const sage = FALLBACK_PALETTE.oklch.anchor;
216
+ assert.ok(sage.c < 0.05 && hueDistance(sage.h, 145) < 2);
217
+ });
218
+
219
+ it("answers the same palette bit-for-bit (seed-blind, vector-only)", () => {
220
+ const d = descriptor({ 12: 9 / 11, 13: 0.6, 31: 0.3, 6: 0.7, 18: 0.3, 22: 0.7, 29: 0.2, 16: 0.5 });
221
+ const a = synthPalette(d);
222
+ assert.deepEqual(synthPalette(d), a);
223
+ assert.deepEqual(synthPalette({ ...d, seed: "ffffffffffffffff" }), a);
224
+ assert.notDeepEqual(synthPalette(descriptor({ 12: 0 })), synthPalette(descriptor({ 12: 6 / 11 })));
225
+ });
226
+
227
+ it("clamps runaway oklch into legal hexes and keeps the luma math sane", () => {
228
+ assert.match(oklchToSrgbHex(5, 9, -7000), HEX_SHAPE);
229
+ assert.equal(oklchToSrgbHex(Number.NaN, Number.NaN, Number.NaN), "#000000");
230
+ assert.ok(Math.abs(relativeLuminance("#ffffff") - 1) < 1e-9);
231
+ assert.equal(relativeLuminance("#000000"), 0);
232
+ assert.equal(relativeLuminance("nope"), 0);
233
+ assert.ok(Math.abs(contrastRatio("#ffffff", "#000000") - 21) < 1e-9);
234
+ assert.equal(contrastRatio("nope", "#000000"), 1);
235
+ });
236
+
237
+ it("reads the descriptor dims where DIM_ORDER says they live", () => {
238
+ assert.equal(DIM_ORDER[6], "brightness");
239
+ assert.equal(DIM_ORDER[12], "tonalCenter");
240
+ assert.equal(DIM_ORDER[13], "keyStrength");
241
+ assert.equal(DIM_ORDER[16], "noisiness");
242
+ assert.equal(DIM_ORDER[18], "timbreBrightness");
243
+ assert.equal(DIM_ORDER[22], "punch");
244
+ assert.equal(DIM_ORDER[29], "energyDrive");
245
+ assert.equal(DIM_ORDER[31], "moodShadow");
246
+ });
247
+ });