@wenathlan/cadria 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 +33 -1
- package/docs/.gitkeep +0 -0
- package/package.json +25 -2
- package/tests/.gitkeep +0 -0
- package/tests/audiodecode.test.ts +406 -0
- package/tests/audiofeatures.test.ts +271 -0
- package/tests/audiopipeline.test.ts +193 -0
- package/tests/audiorhythm.test.ts +172 -0
- package/tests/audiospectrum.test.ts +257 -0
- package/tests/audiostructure.test.ts +292 -0
- package/tests/audiotimbre.test.ts +270 -0
- package/tests/audiotonality.test.ts +289 -0
- package/tests/blendmodes.test.ts +31 -4
- package/tests/colorconvert.test.ts +3 -4
- package/tests/easecurves.test.ts +3 -4
- package/tests/family.test.ts +73 -0
- package/tests/familyurl.test.ts +14 -5
- package/tests/gateway.test.ts +373 -0
- package/tests/generation.test.ts +228 -0
- package/tests/imageexport.test.ts +443 -0
- package/tests/imageproject.test.ts +372 -0
- package/tests/imagerender.test.ts +291 -0
- package/tests/imagerenderpaint.test.ts +119 -0
- package/tests/imagestyles.test.ts +347 -0
- package/tests/masks.test.ts +3 -3
- package/tests/matiz.test.ts +8 -5
- package/tests/mediaprobe.test.ts +66 -22
- package/tests/onboardingstate.test.ts +73 -0
- package/tests/shadercomp.test.ts +120 -0
- package/tests/synthcomposition.test.ts +257 -0
- package/tests/synthmotion.test.ts +372 -0
- package/tests/synthpalette.test.ts +247 -0
- package/tests/synthtexture.test.ts +256 -0
- package/tests/timelineedit.test.ts +3 -4
- package/tests/timelineview.test.ts +248 -0
- package/tests/timeticks.test.ts +4 -4
- package/tests/transcodeplan.test.ts +19 -8
- package/tests/versawaseanimate.test.ts +106 -0
- package/tests/versawasescheduler.test.ts +96 -0
- package/tests/versawaseslices.test.ts +75 -0
- package/tests/visualize.test.ts +6 -6
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// # audiotimbre.test — the timbre layer of the audio→image paint feed
|
|
2
|
+
// (mel/MFCC, envelopes, dynamics, onset shapes and the spectral descriptors),
|
|
3
|
+
// runnable with vitest:
|
|
4
|
+
// pnpm vitest run tests/audiotimbre.test.ts
|
|
5
|
+
// Every test watches the three invariants the module promises: the
|
|
6
|
+
// descriptors separate the fixtures they were built for (noise vs tone, pad
|
|
7
|
+
// vs click, warm vs bright, loud vs quiet), every answer stays finite under
|
|
8
|
+
// damaged input, and the whole layer is deterministic — the same buffer
|
|
9
|
+
// answers the same numbers on every run. Fixtures are synthesized (sines,
|
|
10
|
+
// ramps, an LCG pseudo-noise) so the suite never touches the filesystem.
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { describe, it } from "vitest";
|
|
13
|
+
import {
|
|
14
|
+
brightness,
|
|
15
|
+
DEFAULT_COEFFICIENTS,
|
|
16
|
+
dctii,
|
|
17
|
+
hztomel,
|
|
18
|
+
MEL_BANDS,
|
|
19
|
+
melFilterbank,
|
|
20
|
+
meltohz,
|
|
21
|
+
mfcc,
|
|
22
|
+
noisinessIndex,
|
|
23
|
+
rolloffSharpness,
|
|
24
|
+
spectralSlope,
|
|
25
|
+
timbreStats,
|
|
26
|
+
warmthIndex,
|
|
27
|
+
} from "../audiotimbre.ts";
|
|
28
|
+
import { DB_FLOOR, dynamicStats, onsetShapes, rmsEnvelope, todb, zcr, zcrrate } from "../audiotimbreenv.ts";
|
|
29
|
+
|
|
30
|
+
const SR = 16000;
|
|
31
|
+
|
|
32
|
+
function sine(freq: number, seconds: number, amp = 0.8, sr = SR): Float32Array {
|
|
33
|
+
const n = Math.round(seconds * sr);
|
|
34
|
+
const out = new Float32Array(n);
|
|
35
|
+
for (let i = 0; i < n; i++) out[i] = amp * Math.sin((2 * Math.PI * freq * i) / sr);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** deterministic pseudo-noise (LCG — no Math.random anywhere in this suite). */
|
|
40
|
+
function lcgNoise(n: number, seed = 7): Float32Array {
|
|
41
|
+
let state = seed >>> 0 || 1;
|
|
42
|
+
const out = new Float32Array(n);
|
|
43
|
+
for (let i = 0; i < n; i++) {
|
|
44
|
+
state = (Math.imul(state, 1103515245) + 12345) >>> 0;
|
|
45
|
+
out[i] = state / 2147483648 - 1;
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** one-bin magnitude spectrum (bin k of 257 covers k·31.25 Hz at the suite rate). */
|
|
51
|
+
function spikeAt(bin: number, bins = 257): Float32Array {
|
|
52
|
+
const out = new Float32Array(bins);
|
|
53
|
+
out[bin] = 1;
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function finiteDeep(value: unknown): boolean {
|
|
58
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
59
|
+
if (value instanceof Float32Array || value instanceof Float64Array) return Array.from(value).every(finiteDeep);
|
|
60
|
+
if (Array.isArray(value)) return value.every(finiteDeep);
|
|
61
|
+
if (value && typeof value === "object") return Object.values(value).every(finiteDeep);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("audiotimbre", () => {
|
|
66
|
+
it("answers 26 overlapping mel triangles with unit peaks", () => {
|
|
67
|
+
const bank = melFilterbank(257, SR);
|
|
68
|
+
assert.equal(bank.length, MEL_BANDS);
|
|
69
|
+
for (const row of bank) {
|
|
70
|
+
let peak = 0;
|
|
71
|
+
for (const w of row) {
|
|
72
|
+
assert.ok(w >= 0 && w <= 1);
|
|
73
|
+
peak = Math.max(peak, w);
|
|
74
|
+
}
|
|
75
|
+
assert.ok(peak > 0.9);
|
|
76
|
+
}
|
|
77
|
+
assert.deepEqual(melFilterbank(257, SR), bank);
|
|
78
|
+
assert.deepEqual(melFilterbank(257, Number.NaN)[0], new Float32Array(257));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("round-trips the HTK mel scale", () => {
|
|
82
|
+
assert.ok(Math.abs(hztomel(1000) - 1000.5) < 1);
|
|
83
|
+
assert.ok(Math.abs(meltohz(hztomel(440)) - 440) < 1e-6);
|
|
84
|
+
assert.equal(hztomel(0), 0);
|
|
85
|
+
assert.equal(hztomel(Number.NaN), 0);
|
|
86
|
+
assert.equal(meltohz(-5), 0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("matches the known DCT-II of [1, 2, 3, 4]", () => {
|
|
90
|
+
const out = dctii([1, 2, 3, 4]);
|
|
91
|
+
assert.ok(Math.abs(out[0] - 10) < 1e-9);
|
|
92
|
+
assert.ok(Math.abs(out[1] + 3.1543220298889496) < 1e-9);
|
|
93
|
+
assert.ok(Math.abs(out[2]) < 1e-9);
|
|
94
|
+
assert.ok(Math.abs(out[3] + 0.2241707645839828) < 1e-9);
|
|
95
|
+
const flat = dctii([3, 3, 3, 3]);
|
|
96
|
+
assert.ok(Math.abs(flat[0] - 12) < 1e-9);
|
|
97
|
+
for (let k = 1; k < 4; k++) assert.ok(Math.abs(flat[k]) < 1e-9);
|
|
98
|
+
assert.equal(dctii([]).length, 0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("answers 13 finite coefficients for a sine-like magnitude frame", () => {
|
|
102
|
+
const out = mfcc(spikeAt(16), SR);
|
|
103
|
+
assert.equal(out.length, DEFAULT_COEFFICIENTS);
|
|
104
|
+
for (const v of out) assert.ok(Number.isFinite(v));
|
|
105
|
+
const again = mfcc(spikeAt(16), SR);
|
|
106
|
+
assert.deepEqual(again, out);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("keeps the first mfcc coefficient stable across same-pitch frames", () => {
|
|
110
|
+
const stats = timbreStats(sine(440, 0.6), SR);
|
|
111
|
+
assert.ok(stats.mfccStd[0] < 0.5);
|
|
112
|
+
assert.ok(finiteDeep(stats.mfccMean) && finiteDeep(stats.mfccStd));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("holds a constant-level sine steady in the rms envelope", () => {
|
|
116
|
+
const env = rmsEnvelope(sine(440, 0.5, 0.5));
|
|
117
|
+
assert.ok(env.length >= 14);
|
|
118
|
+
const target = 0.5 / Math.SQRT2;
|
|
119
|
+
for (const v of env) assert.ok(Math.abs(v - target) < 0.05 * target);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("floors silence at −90 dB and reads full scale as 0 dB", () => {
|
|
123
|
+
assert.equal(todb(0), DB_FLOOR);
|
|
124
|
+
assert.equal(todb(1e-12), DB_FLOOR);
|
|
125
|
+
assert.equal(todb(Number.NaN), DB_FLOOR);
|
|
126
|
+
assert.equal(todb(1), 0);
|
|
127
|
+
assert.ok(Math.abs(todb(0.1) + 20) < 1e-9);
|
|
128
|
+
const silent = dynamicStats(rmsEnvelope(new Float32Array(4096)));
|
|
129
|
+
assert.equal(silent.minDb, DB_FLOOR);
|
|
130
|
+
assert.equal(silent.maxDb, DB_FLOOR);
|
|
131
|
+
assert.equal(silent.rangeDb, 0);
|
|
132
|
+
assert.equal(silent.crestFactor, 1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("reads a crescendo with a wide dynamic range", () => {
|
|
136
|
+
const n = SR;
|
|
137
|
+
const crescendo = new Float32Array(n);
|
|
138
|
+
for (let i = 0; i < n; i++) crescendo[i] = 0.01 + (0.99 * i) / (n - 1);
|
|
139
|
+
const stats = dynamicStats(rmsEnvelope(crescendo));
|
|
140
|
+
assert.ok(stats.rangeDb > 10);
|
|
141
|
+
assert.ok(stats.crestFactor >= 1);
|
|
142
|
+
assert.ok(stats.maxDb > stats.meanDb && stats.meanDb > stats.minDb);
|
|
143
|
+
assert.ok(finiteDeep(stats));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("separates a fast click attack from a slow pad attack", () => {
|
|
147
|
+
const click = new Float32Array(4000);
|
|
148
|
+
for (let i = 0; i < 64; i++) click[i] = 0.9 * Math.exp(-i / 16);
|
|
149
|
+
const clickShape = onsetShapes(rmsEnvelope(click, { size: 256, hop: 128 }), [0], 8000, { hop: 128 })[0];
|
|
150
|
+
assert.ok(clickShape.attackMs < 10);
|
|
151
|
+
assert.ok(clickShape.peak > 0);
|
|
152
|
+
const pad = new Float32Array(8000);
|
|
153
|
+
for (let i = 0; i < 8000; i++) {
|
|
154
|
+
const t = i / 8000;
|
|
155
|
+
pad[i] = (t < 0.3 ? 0.01 + (0.99 * t) / 0.3 : 1) * Math.sin((2 * Math.PI * 220 * i) / 8000);
|
|
156
|
+
}
|
|
157
|
+
const padShape = onsetShapes(rmsEnvelope(pad, { size: 512, hop: 128 }), [0], 8000, { hop: 128 })[0];
|
|
158
|
+
assert.ok(padShape.attackMs > 50);
|
|
159
|
+
assert.ok(Number.isFinite(clickShape.decayMs) && clickShape.decayMs >= 0);
|
|
160
|
+
assert.ok(Number.isFinite(padShape.decayMs) && padShape.decayMs >= 0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("tolerates empty and out-of-range onset lists", () => {
|
|
164
|
+
const env = rmsEnvelope(sine(440, 0.25));
|
|
165
|
+
assert.deepEqual(onsetShapes(env, [], SR), []);
|
|
166
|
+
const shapes = onsetShapes(env, [-100, Number.NaN, 1e9], SR);
|
|
167
|
+
assert.equal(shapes.length, 3);
|
|
168
|
+
for (const shape of shapes) {
|
|
169
|
+
assert.ok(finiteDeep(shape) && shape.peak >= 0 && shape.attackMs >= 0 && shape.decayMs >= 0);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("ranks the zero-crossing rate of a high sine above a low one", () => {
|
|
174
|
+
const high = zcrrate(sine(3000, 0.25));
|
|
175
|
+
const low = zcrrate(sine(300, 0.25));
|
|
176
|
+
assert.ok(high > 0.2 && low < 0.1);
|
|
177
|
+
assert.ok(high > low);
|
|
178
|
+
const frames = zcr(sine(3000, 0.25));
|
|
179
|
+
assert.ok(frames.length > 0 && Array.from(frames).every((v) => v > 0.2));
|
|
180
|
+
assert.equal(zcrrate(new Float32Array(0)), 0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("prefers the low-passed fixture in warmth", () => {
|
|
184
|
+
assert.ok(warmthIndex(spikeAt(8), SR) > 0.99);
|
|
185
|
+
assert.ok(warmthIndex(spikeAt(128), SR) < 0.01);
|
|
186
|
+
const warmStats = timbreStats(sine(250, 0.5), SR);
|
|
187
|
+
const brightStats = timbreStats(sine(4000, 0.5), SR);
|
|
188
|
+
assert.ok(warmStats.warmth > 0.9);
|
|
189
|
+
assert.ok(brightStats.warmth < 0.1);
|
|
190
|
+
assert.ok(warmStats.warmth > brightStats.warmth);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("ranks noise above a tone in noisiness", () => {
|
|
194
|
+
const flat = new Float32Array(257).fill(1);
|
|
195
|
+
const tone = spikeAt(40);
|
|
196
|
+
const noiseMags = new Float32Array(257);
|
|
197
|
+
const rng = lcgNoise(257, 3);
|
|
198
|
+
for (let k = 0; k < 257; k++) noiseMags[k] = 0.1 + Math.abs(rng[k]) * 0.9;
|
|
199
|
+
assert.ok(noisinessIndex(flat) > 0.99);
|
|
200
|
+
assert.ok(noisinessIndex(tone) < 0.01);
|
|
201
|
+
assert.ok(noisinessIndex(noiseMags) > noisinessIndex(tone));
|
|
202
|
+
const noiseStats = timbreStats(lcgNoise(8000), SR);
|
|
203
|
+
const toneStats = timbreStats(sine(440, 0.5), SR);
|
|
204
|
+
assert.ok(noiseStats.noisiness > 0.2);
|
|
205
|
+
assert.ok(toneStats.noisiness < 0.1);
|
|
206
|
+
assert.ok(noiseStats.noisiness > toneStats.noisiness);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("reads slope, rolloff sharpness and centroid from tilted spectra", () => {
|
|
210
|
+
const bins = 257;
|
|
211
|
+
const falling = new Float32Array(bins);
|
|
212
|
+
const rising = new Float32Array(bins);
|
|
213
|
+
const even = new Float32Array(bins);
|
|
214
|
+
for (let k = 0; k < bins; k++) {
|
|
215
|
+
falling[k] = 1 / (k + 1);
|
|
216
|
+
rising[k] = k + 1;
|
|
217
|
+
even[k] = 1;
|
|
218
|
+
}
|
|
219
|
+
assert.ok(spectralSlope(falling, SR) < -0.8);
|
|
220
|
+
assert.ok(spectralSlope(rising, SR) > 0.8);
|
|
221
|
+
assert.ok(Math.abs(spectralSlope(even, SR)) < 1e-6);
|
|
222
|
+
assert.ok(Math.abs(rolloffSharpness(spikeAt(8)) - 8 / 256) < 1e-9);
|
|
223
|
+
assert.ok(Math.abs(rolloffSharpness(spikeAt(240)) - 240 / 256) < 1e-9);
|
|
224
|
+
assert.equal(rolloffSharpness(new Float32Array(257)), 0);
|
|
225
|
+
assert.ok(Math.abs(brightness(spikeAt(8), SR) - 8 / 256) < 1e-9);
|
|
226
|
+
assert.ok(Math.abs(brightness(spikeAt(128), SR) - 0.5) < 1e-9);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("answers the full TimbreStats contract shape", () => {
|
|
230
|
+
const stats = timbreStats(sine(440, 0.6), SR);
|
|
231
|
+
assert.equal(stats.mfccMean.length, 13);
|
|
232
|
+
assert.equal(stats.mfccStd.length, 13);
|
|
233
|
+
for (const v of stats.mfccMean) assert.ok(Number.isFinite(v));
|
|
234
|
+
for (const v of stats.mfccStd) assert.ok(v >= 0 && Number.isFinite(v));
|
|
235
|
+
assert.ok(stats.zcrMean >= 0 && stats.zcrMean <= 1);
|
|
236
|
+
assert.ok(stats.noisiness >= 0 && stats.noisiness <= 1);
|
|
237
|
+
assert.ok(stats.warmth >= 0 && stats.warmth <= 1);
|
|
238
|
+
assert.ok(stats.brightness >= 0 && stats.brightness <= 1);
|
|
239
|
+
assert.ok(Number.isFinite(stats.slopeMean));
|
|
240
|
+
assert.ok(stats.dynamics.rangeDb >= 0 && stats.dynamics.crestFactor >= 1);
|
|
241
|
+
const silent = timbreStats(new Float32Array(4096), SR);
|
|
242
|
+
assert.equal(silent.brightness, 0);
|
|
243
|
+
assert.equal(silent.noisiness, 0);
|
|
244
|
+
assert.equal(silent.warmth, 0.5);
|
|
245
|
+
assert.equal(silent.dynamics.minDb, DB_FLOOR);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("is deterministic across runs", () => {
|
|
249
|
+
const mixed = new Float32Array(8000);
|
|
250
|
+
const noise = lcgNoise(8000, 11);
|
|
251
|
+
for (let i = 0; i < 8000; i++) mixed[i] = 0.5 * Math.sin((2 * Math.PI * 440 * i) / SR) + 0.2 * noise[i];
|
|
252
|
+
assert.deepEqual(timbreStats(mixed, SR), timbreStats(mixed, SR));
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("stays finite on damaged input everywhere", () => {
|
|
256
|
+
const broken = new Float32Array([Number.NaN, 1, Number.POSITIVE_INFINITY, -2, 0]);
|
|
257
|
+
assert.ok(finiteDeep(rmsEnvelope(broken)));
|
|
258
|
+
assert.ok(finiteDeep(dynamicStats([Number.NaN, 0.5, Number.POSITIVE_INFINITY, 0])));
|
|
259
|
+
assert.ok(finiteDeep(zcr(broken)) && finiteDeep(zcrrate(broken)));
|
|
260
|
+
assert.ok(finiteDeep(mfcc([Number.NaN, 2, Number.NaN, 1, 0, 3], SR)));
|
|
261
|
+
assert.ok(finiteDeep(spectralSlope([Number.NaN, 1, 2, 0, 4], SR)));
|
|
262
|
+
assert.ok(Number.isFinite(noisinessIndex([Number.NaN, 1, Number.NaN])));
|
|
263
|
+
assert.ok(Number.isFinite(warmthIndex([Number.NaN, 1, 2], Number.NaN)));
|
|
264
|
+
assert.ok(Number.isFinite(brightness([Number.NaN, 1], SR)));
|
|
265
|
+
const shapes = onsetShapes(new Float32Array(0), [Number.NaN], SR);
|
|
266
|
+
assert.equal(shapes.length, 1);
|
|
267
|
+
assert.ok(finiteDeep(shapes));
|
|
268
|
+
assert.ok(finiteDeep(timbreStats(broken, SR)));
|
|
269
|
+
});
|
|
270
|
+
});
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// # audiotonality.test — the tonality layer: the hann-windowed STFT stand-in,
|
|
2
|
+
// the a440 bin→pitch-class chroma fold, the Krumhansl-Schmuckler key estimate,
|
|
3
|
+
// the chord timeline with greedy smoothing and the HarmonicStats summary,
|
|
4
|
+
// runnable with vitest:
|
|
5
|
+
// pnpm vitest run tests/audiotonality.test.ts
|
|
6
|
+
// Every fixture is a deterministic synthetic waveform (sines at
|
|
7
|
+
// equal-tempered a440 pitches, octave 4+) and every test watches the two
|
|
8
|
+
// invariants the module promises: clean tonal input lands on the right
|
|
9
|
+
// tonic/chord, and damaged or silent input answers zeros or an empty
|
|
10
|
+
// timeline instead of NaN.
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { describe, it } from "vitest";
|
|
13
|
+
import {
|
|
14
|
+
chromagram,
|
|
15
|
+
chromaVector,
|
|
16
|
+
estimateKey,
|
|
17
|
+
harmonicStats,
|
|
18
|
+
keyName,
|
|
19
|
+
keyProfiles,
|
|
20
|
+
pitchClassNames,
|
|
21
|
+
spectrumFrames,
|
|
22
|
+
} from "../audiotonality.ts";
|
|
23
|
+
import { CHORD_INTERVALS, chordTemplates, detectChords } from "../audiotonalitychords.ts";
|
|
24
|
+
|
|
25
|
+
const RATE = 44100;
|
|
26
|
+
const FRAME_MS = (1000 * 2048) / 44100;
|
|
27
|
+
|
|
28
|
+
function noteHz(midi: number): number {
|
|
29
|
+
return 440 * 2 ** ((midi - 69) / 12);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function tone(freq: number, seconds: number, amp: number): Float32Array {
|
|
33
|
+
const count = Math.round(seconds * RATE);
|
|
34
|
+
const out = new Float32Array(count);
|
|
35
|
+
for (let i = 0; i < count; i += 1) out[i] = amp * Math.sin((2 * Math.PI * freq * i) / RATE);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function chord(parts: readonly Float32Array[]): Float32Array {
|
|
40
|
+
const total = Math.max(...parts.map((part) => part.length));
|
|
41
|
+
const out = new Float32Array(total);
|
|
42
|
+
for (const part of parts) for (let i = 0; i < part.length; i += 1) out[i] += part[i];
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sequence(parts: readonly Float32Array[]): Float32Array {
|
|
47
|
+
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
48
|
+
const out = new Float32Array(total);
|
|
49
|
+
let at = 0;
|
|
50
|
+
for (const part of parts) {
|
|
51
|
+
out.set(part, at);
|
|
52
|
+
at += part.length;
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function meanChromaOf(frames: readonly Float32Array[]): Float32Array {
|
|
58
|
+
const mean = new Float32Array(12);
|
|
59
|
+
for (const frame of frames) for (let i = 0; i < 12; i += 1) mean[i] += frame[i];
|
|
60
|
+
for (let i = 0; i < 12; i += 1) mean[i] /= frames.length;
|
|
61
|
+
return mean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** sustained triad chroma frame (1/3 per chord tone) built from the shared interval table. */
|
|
65
|
+
function triadChroma(root: number, quality: "maj" | "min"): Float32Array {
|
|
66
|
+
const chroma = new Float32Array(12);
|
|
67
|
+
for (const interval of CHORD_INTERVALS[quality]) chroma[(root + interval) % 12] = 1 / 3;
|
|
68
|
+
return chroma;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// sustained c major scale (c4…b4) with the tonic triad twice the amplitude of the passing tones
|
|
72
|
+
const C_MAJOR_SAMPLES = chord([
|
|
73
|
+
tone(noteHz(60), 1, 0.3),
|
|
74
|
+
tone(noteHz(62), 1, 0.12),
|
|
75
|
+
tone(noteHz(64), 1, 0.3),
|
|
76
|
+
tone(noteHz(65), 1, 0.12),
|
|
77
|
+
tone(noteHz(67), 1, 0.3),
|
|
78
|
+
tone(noteHz(69), 1, 0.12),
|
|
79
|
+
tone(noteHz(71), 1, 0.12),
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
// sustained a natural minor (a3…g4) with the a minor triad doubled in amplitude
|
|
83
|
+
const A_MINOR_SAMPLES = chord([
|
|
84
|
+
tone(noteHz(57), 1, 0.3),
|
|
85
|
+
tone(noteHz(59), 1, 0.12),
|
|
86
|
+
tone(noteHz(60), 1, 0.3),
|
|
87
|
+
tone(noteHz(62), 1, 0.12),
|
|
88
|
+
tone(noteHz(64), 1, 0.3),
|
|
89
|
+
tone(noteHz(65), 1, 0.12),
|
|
90
|
+
tone(noteHz(67), 1, 0.12),
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
// the c–f–g–c progression, four 0.4 s major triads in octave 4/5
|
|
94
|
+
const CHORD_SECONDS = 0.4;
|
|
95
|
+
function triadSamples(rootMidi: number): Float32Array {
|
|
96
|
+
return chord([0, 4, 7].map((interval) => tone(noteHz(rootMidi + interval), CHORD_SECONDS, 0.25)));
|
|
97
|
+
}
|
|
98
|
+
const PROGRESSION_SAMPLES = sequence([triadSamples(60), triadSamples(65), triadSamples(67), triadSamples(60)]);
|
|
99
|
+
|
|
100
|
+
const SINE_SAMPLES = tone(440, 1, 0.2); // pure a440
|
|
101
|
+
const PROGRESSION_CHROMA = chromagram(spectrumFrames(PROGRESSION_SAMPLES), RATE);
|
|
102
|
+
|
|
103
|
+
describe("audiotonality chroma", () => {
|
|
104
|
+
it("folds a pure a440 sine into chroma bin 9", () => {
|
|
105
|
+
const [frame] = spectrumFrames(SINE_SAMPLES);
|
|
106
|
+
assert.ok(frame);
|
|
107
|
+
const chroma = chromaVector(frame, RATE);
|
|
108
|
+
for (let pc = 0; pc < 12; pc += 1) {
|
|
109
|
+
if (pc === 9) continue;
|
|
110
|
+
assert.ok(chroma[9] > chroma[pc], `bin 9 dominates bin ${pc}`);
|
|
111
|
+
}
|
|
112
|
+
assert.ok(chroma[9] > 0.7);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("normalizes chroma to a unit l1 sum and guards NaN magnitudes", () => {
|
|
116
|
+
const magnitudes = new Float32Array(513);
|
|
117
|
+
magnitudes[5] = 2; // 5 × 44100/1024 ≈ 215 Hz → pitch class 9
|
|
118
|
+
magnitudes[2] = Number.NaN;
|
|
119
|
+
magnitudes[7] = Number.POSITIVE_INFINITY;
|
|
120
|
+
const chroma = chromaVector(magnitudes, RATE);
|
|
121
|
+
const sum = chroma.reduce((total, value) => total + value, 0);
|
|
122
|
+
assert.ok(Math.abs(sum - 1) < 1e-3);
|
|
123
|
+
assert.ok(Math.abs(chroma[9] - 1) < 1e-3);
|
|
124
|
+
assert.equal(
|
|
125
|
+
chroma.some((value) => !Number.isFinite(value)),
|
|
126
|
+
false,
|
|
127
|
+
);
|
|
128
|
+
const poisoned = new Float32Array(513).fill(Number.NaN);
|
|
129
|
+
const silent = chromaVector(poisoned, RATE);
|
|
130
|
+
assert.equal(
|
|
131
|
+
silent.reduce((total, value) => total + value, 0),
|
|
132
|
+
0,
|
|
133
|
+
);
|
|
134
|
+
assert.equal(
|
|
135
|
+
silent.some((value) => !Number.isFinite(value)),
|
|
136
|
+
false,
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("shapes one 12-bin frame per stft hop", () => {
|
|
141
|
+
const frames = spectrumFrames(SINE_SAMPLES);
|
|
142
|
+
assert.equal(frames.length, Math.floor((SINE_SAMPLES.length - 4096) / 2048) + 1);
|
|
143
|
+
assert.equal(frames[0].length, 2049);
|
|
144
|
+
const chroma = chromagram(frames, RATE);
|
|
145
|
+
assert.equal(chroma.length, frames.length);
|
|
146
|
+
for (const frame of chroma) {
|
|
147
|
+
assert.equal(frame.length, 12);
|
|
148
|
+
assert.ok(Math.abs(frame.reduce((total, value) => total + value, 0) - 1) < 1e-3);
|
|
149
|
+
}
|
|
150
|
+
assert.equal(chromagram(frames, RATE, 0).length, 0);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("audiotonality key", () => {
|
|
155
|
+
it("names pitch classes and keys in lowercase", () => {
|
|
156
|
+
assert.deepEqual([...pitchClassNames], ["c", "c#", "d", "d#", "e", "f", "f#", "g", "g#", "a", "a#", "b"]);
|
|
157
|
+
assert.equal(keyName({ tonic: 0, mode: "major" }), "c major");
|
|
158
|
+
assert.equal(keyName({ tonic: 9, mode: "minor" }), "a minor");
|
|
159
|
+
assert.equal(keyName({ tonic: Number.NaN, mode: "major" }), "");
|
|
160
|
+
assert.equal(keyProfiles.major.length, 12);
|
|
161
|
+
assert.equal(keyProfiles.minor.length, 12);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("hears c major in the c major scale fixture", () => {
|
|
165
|
+
const chroma = chromagram(spectrumFrames(C_MAJOR_SAMPLES), RATE);
|
|
166
|
+
const key = estimateKey(meanChromaOf(chroma));
|
|
167
|
+
assert.equal(key.tonic, 0);
|
|
168
|
+
assert.equal(key.mode, "major");
|
|
169
|
+
assert.ok(key.strength > 0.5, `strength ${key.strength}`);
|
|
170
|
+
assert.equal(keyName(key), "c major");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("hears a minor in the a minor fixture", () => {
|
|
174
|
+
const chroma = chromagram(spectrumFrames(A_MINOR_SAMPLES), RATE);
|
|
175
|
+
const key = estimateKey(meanChromaOf(chroma));
|
|
176
|
+
assert.equal(key.tonic, 9);
|
|
177
|
+
assert.equal(key.mode, "minor");
|
|
178
|
+
assert.ok(key.strength > 0.5, `strength ${key.strength}`);
|
|
179
|
+
assert.equal(keyName(key), "a minor");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("ranks the 23 alternatives below the winner", () => {
|
|
183
|
+
const key = estimateKey(meanChromaOf(chromagram(spectrumFrames(C_MAJOR_SAMPLES), RATE)));
|
|
184
|
+
assert.equal(key.alternatives.length, 23);
|
|
185
|
+
assert.ok(key.alternatives.every((candidate) => candidate.strength <= key.strength));
|
|
186
|
+
for (let i = 1; i < key.alternatives.length; i += 1) {
|
|
187
|
+
assert.ok(key.alternatives[i - 1].strength >= key.alternatives[i].strength, `rank ${i}`);
|
|
188
|
+
}
|
|
189
|
+
assert.ok(key.alternatives.every((candidate) => candidate.tonic >= 0 && candidate.tonic <= 11));
|
|
190
|
+
assert.ok(key.alternatives.every((candidate) => candidate.mode === "major" || candidate.mode === "minor"));
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("audiotonalitychords", () => {
|
|
195
|
+
it("folds the 7 qualities over 12 roots into 84 templates", () => {
|
|
196
|
+
assert.equal(chordTemplates.length, 84);
|
|
197
|
+
assert.equal(new Set(chordTemplates.map((template) => template.quality)).size, 7);
|
|
198
|
+
const maj = chordTemplates[0];
|
|
199
|
+
assert.equal(maj.root, 0);
|
|
200
|
+
assert.equal(maj.quality, "maj");
|
|
201
|
+
for (let pc = 0; pc < 12; pc += 1) {
|
|
202
|
+
assert.equal(maj.weights[pc], CHORD_INTERVALS.maj.includes(pc) ? 1 : 0);
|
|
203
|
+
}
|
|
204
|
+
assert.ok(chordTemplates.every((template) => template.weights.reduce((total, value) => total + value, 0) >= 1));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("traces the c–f–g–c progression into four major segments", () => {
|
|
208
|
+
const timeline = detectChords(PROGRESSION_CHROMA, { minFramesPerChord: 4, frameMs: FRAME_MS });
|
|
209
|
+
assert.equal(timeline.length, 4);
|
|
210
|
+
assert.deepEqual(
|
|
211
|
+
timeline.map((segment) => segment.root),
|
|
212
|
+
[0, 5, 7, 0],
|
|
213
|
+
);
|
|
214
|
+
assert.ok(timeline.every((segment) => segment.quality === "maj"));
|
|
215
|
+
for (let i = 0; i < timeline.length; i += 1) {
|
|
216
|
+
assert.ok(Number.isFinite(timeline[i].startMs) && Number.isFinite(timeline[i].endMs));
|
|
217
|
+
assert.ok(timeline[i].endMs > timeline[i].startMs);
|
|
218
|
+
assert.ok(timeline[i].confidence > 0 && timeline[i].confidence <= 1);
|
|
219
|
+
if (i > 0) assert.ok(timeline[i].startMs >= timeline[i - 1].endMs, `segment ${i} monotonic`);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("merges rapid chord flips into one segment", () => {
|
|
224
|
+
const frames: Float32Array[] = [];
|
|
225
|
+
for (let i = 0; i < 12; i += 1) frames.push(i % 2 === 0 ? triadChroma(0, "maj") : triadChroma(5, "maj"));
|
|
226
|
+
const timeline = detectChords(frames, { minFramesPerChord: 3, frameMs: 100 });
|
|
227
|
+
assert.equal(timeline.length, 1);
|
|
228
|
+
assert.equal(timeline[0].quality, "maj");
|
|
229
|
+
assert.equal(timeline[0].startMs, 0);
|
|
230
|
+
assert.equal(timeline[0].endMs, 1200);
|
|
231
|
+
assert.ok(Number.isFinite(timeline[0].confidence) && timeline[0].confidence <= 1);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("answers an empty timeline for empty or silent chromagrams", () => {
|
|
235
|
+
assert.equal(detectChords([], { minFramesPerChord: 1 }).length, 0);
|
|
236
|
+
assert.equal(detectChords([new Float32Array(12), new Float32Array(12)], { minFramesPerChord: 1 }).length, 0);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("harmonicstats", () => {
|
|
241
|
+
it("scores flat noise more dissonant than a major triad", () => {
|
|
242
|
+
const flat: Float32Array[] = Array.from({ length: 12 }, () => new Float32Array(12).fill(1 / 12));
|
|
243
|
+
const triad: Float32Array[] = Array.from({ length: 12 }, () => triadChroma(0, "maj"));
|
|
244
|
+
const flatStats = harmonicStats(flat, RATE);
|
|
245
|
+
const triadStats = harmonicStats(triad, RATE);
|
|
246
|
+
assert.ok(flatStats.dissonanceIndex > triadStats.dissonanceIndex);
|
|
247
|
+
assert.ok(flatStats.dissonanceIndex > 0.95);
|
|
248
|
+
assert.ok(triadStats.dissonanceIndex < 0.6);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("counts chord changes per second across the progression", () => {
|
|
252
|
+
const stats = harmonicStats(PROGRESSION_CHROMA, RATE);
|
|
253
|
+
assert.ok(stats.harmonicChangeRate > 2 && stats.harmonicChangeRate < 3.5, `rate ${stats.harmonicChangeRate}`);
|
|
254
|
+
const energy = stats.chromaEnergy.reduce((total, value) => total + value, 0);
|
|
255
|
+
assert.ok(Math.abs(energy - 1) < 1e-3);
|
|
256
|
+
assert.equal(stats.chromaEnergy.length, 12);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("repeats bit-identical stats for the same fixture", () => {
|
|
260
|
+
const mean = meanChromaOf(PROGRESSION_CHROMA);
|
|
261
|
+
assert.deepEqual(estimateKey(mean), estimateKey(mean));
|
|
262
|
+
assert.deepEqual(harmonicStats(PROGRESSION_CHROMA, RATE), harmonicStats(PROGRESSION_CHROMA, RATE, { hop: 2048 }));
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("keeps every stats field finite and NaN-free", () => {
|
|
266
|
+
const stats = harmonicStats(PROGRESSION_CHROMA, RATE);
|
|
267
|
+
const flat = harmonicStats(
|
|
268
|
+
Array.from({ length: 4 }, () => new Float32Array(12)),
|
|
269
|
+
RATE,
|
|
270
|
+
);
|
|
271
|
+
const empty = harmonicStats([], RATE);
|
|
272
|
+
for (const candidate of [stats, flat, empty]) {
|
|
273
|
+
assert.ok(Number.isFinite(candidate.key.strength));
|
|
274
|
+
assert.ok(Number.isFinite(candidate.harmonicChangeRate));
|
|
275
|
+
assert.ok(Number.isFinite(candidate.dissonanceIndex));
|
|
276
|
+
assert.ok(candidate.chromaEnergy.every(Number.isFinite));
|
|
277
|
+
assert.ok(candidate.key.alternatives.every((alternative) => Number.isFinite(alternative.strength)));
|
|
278
|
+
}
|
|
279
|
+
assert.equal(empty.key.strength, 0);
|
|
280
|
+
assert.equal(empty.harmonicChangeRate, 0);
|
|
281
|
+
assert.equal(empty.dissonanceIndex, 0);
|
|
282
|
+
const timeline = detectChords(PROGRESSION_CHROMA, { frameMs: FRAME_MS });
|
|
283
|
+
assert.ok(
|
|
284
|
+
timeline.every((segment) =>
|
|
285
|
+
[segment.startMs, segment.endMs, segment.root, segment.confidence].every(Number.isFinite),
|
|
286
|
+
),
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
});
|
package/tests/blendmodes.test.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
// # blendmodes.test — the separable blend formulas of the compositing
|
|
2
2
|
// standard over the 0-1 and 0-255 domains, the non-separable luminance
|
|
3
|
-
// modes and the W3C alpha composite, runnable with
|
|
4
|
-
//
|
|
5
|
-
// node --test tests/blendmodes.test.ts
|
|
3
|
+
// modes and the W3C alpha composite, runnable with vitest:
|
|
4
|
+
// pnpm vitest run tests/blendmodes.test.ts
|
|
6
5
|
// Every test watches the two invariants the module promises: the channel
|
|
7
6
|
// formulas match the published equations exactly and clamped inputs
|
|
8
7
|
// never throw — damaged input answers the clamped or source value.
|
|
9
8
|
import assert from "node:assert/strict";
|
|
10
|
-
import { describe, it } from "
|
|
9
|
+
import { describe, it } from "vitest";
|
|
11
10
|
import {
|
|
12
11
|
alphacomposite,
|
|
13
12
|
blendchannel,
|
|
14
13
|
blendchannel01,
|
|
14
|
+
blendlayerstack,
|
|
15
15
|
blendpixel,
|
|
16
16
|
blendrgb,
|
|
17
17
|
blendrgb01,
|
|
@@ -90,4 +90,31 @@ describe("blendmodes", () => {
|
|
|
90
90
|
assert.deepEqual(blendpixel("multiply", [200, 200, 200], [100, 100, 100]), [78, 78, 78]);
|
|
91
91
|
assert.deepEqual(blendpixel("normal", [200, 200, 200], [10, 20, 30]), [10, 20, 30]);
|
|
92
92
|
});
|
|
93
|
+
|
|
94
|
+
it("folds the layer stack and answers null on any dimension mismatch", () => {
|
|
95
|
+
const backdrop = { width: 1, height: 1, data: new Uint8ClampedArray([200, 200, 200, 255]) };
|
|
96
|
+
const gray = { width: 1, height: 1, data: new Uint8ClampedArray([100, 100, 100, 255]) };
|
|
97
|
+
const odd = { width: 2, height: 1, data: new Uint8ClampedArray([0, 0, 0, 255, 0, 0, 0, 255]) };
|
|
98
|
+
const folded = blendlayerstack("multiply", backdrop, [gray]);
|
|
99
|
+
assert.ok(folded);
|
|
100
|
+
assert.deepEqual([...folded.data.slice(0, 3)], [78, 78, 78]);
|
|
101
|
+
assert.equal(blendlayerstack("multiply", backdrop, [odd]), null);
|
|
102
|
+
assert.equal(blendlayerstack("multiply", odd, [gray]), null);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("lets one layer override the stack-wide mode with its own blend identity", () => {
|
|
106
|
+
const backdrop = { width: 1, height: 1, data: new Uint8ClampedArray([200, 200, 200, 255]) };
|
|
107
|
+
const gray = { width: 1, height: 1, data: new Uint8ClampedArray([100, 100, 100, 255]) };
|
|
108
|
+
// stack rides normal (source passthrough); the overriding layer rides multiply.
|
|
109
|
+
const overridden = blendlayerstack("normal", backdrop, [{ ...gray, mode: "multiply" }]);
|
|
110
|
+
assert.ok(overridden);
|
|
111
|
+
assert.deepEqual([...overridden.data.slice(0, 3)], [78, 78, 78]);
|
|
112
|
+
// without the override the same stack rides the stack-wide multiply anyway.
|
|
113
|
+
const plain = blendlayerstack("multiply", backdrop, [gray]);
|
|
114
|
+
assert.deepEqual([...(plain as { data: Uint8ClampedArray }).data.slice(0, 3)], [...overridden.data.slice(0, 3)]);
|
|
115
|
+
// an explicit normal layer over the multiply stack passes the source through.
|
|
116
|
+
const passthrough = blendlayerstack("multiply", backdrop, [{ ...gray, mode: "normal" }]);
|
|
117
|
+
assert.ok(passthrough);
|
|
118
|
+
assert.deepEqual([...passthrough.data.slice(0, 3)], [100, 100, 100]);
|
|
119
|
+
});
|
|
93
120
|
});
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
// # colorconvert.test — the color space conversions of the studio: the
|
|
2
2
|
// sRGB transfer curve through the decode table, the HSL and HSV cylinders,
|
|
3
3
|
// the OKLab and OKLCH spheres, the studio-swing YCbCr families and the
|
|
4
|
-
// hex round trip, runnable with
|
|
5
|
-
//
|
|
6
|
-
// node --test tests/colorconvert.test.ts
|
|
4
|
+
// hex round trip, runnable with vitest:
|
|
5
|
+
// pnpm vitest run tests/colorconvert.test.ts
|
|
7
6
|
// Every test watches the two invariants the module promises: conversions
|
|
8
7
|
// round trip inside one byte of drift and clamped or damaged input never
|
|
9
8
|
// throws — the channel clamp answers the nearest legal byte.
|
|
10
9
|
import assert from "node:assert/strict";
|
|
11
|
-
import { describe, it } from "
|
|
10
|
+
import { describe, it } from "vitest";
|
|
12
11
|
import {
|
|
13
12
|
clamp01,
|
|
14
13
|
clamprgb,
|
package/tests/easecurves.test.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
// # easecurves.test — the influence and speed easing model: the handle
|
|
2
2
|
// algebra of the presets, the cubic bezier solver, the linear detection
|
|
3
|
-
// and the key track evaluation with hold segments, runnable with
|
|
4
|
-
//
|
|
5
|
-
// node --test tests/easecurves.test.ts
|
|
3
|
+
// and the key track evaluation with hold segments, runnable with vitest:
|
|
4
|
+
// pnpm vitest run tests/easecurves.test.ts
|
|
6
5
|
// Every test watches the two invariants the module promises: the linear
|
|
7
6
|
// preset answers the identity of its parameter and damaged curves or
|
|
8
7
|
// times answer null or the nearest key instead of throwing.
|
|
9
8
|
import assert from "node:assert/strict";
|
|
10
|
-
import { describe, it } from "
|
|
9
|
+
import { describe, it } from "vitest";
|
|
11
10
|
import {
|
|
12
11
|
bezierprogress,
|
|
13
12
|
curvefromhandles,
|