@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,271 @@
1
+ // # audiofeatures.test — the descriptor contract of the cadria audio wave,
2
+ // runnable with vitest:
3
+ // pnpm vitest run tests/audiofeatures.test.ts
4
+ // The tests watch the three promises the module makes: the 32-dim vector is
5
+ // total (right length, finite, clamped 0-1) no matter how damaged the input,
6
+ // the answer is deterministic (same stats or same pcm → same descriptor and
7
+ // seed, different audio → different seed), and the documented monotonic
8
+ // mappings hold (louder dynamics range → higher contrast dim, faster bpm →
9
+ // higher tempo dim) so the image-mapping wave can trust every dim by index.
10
+ import assert from "node:assert/strict";
11
+ import { describe, it } from "vitest";
12
+ import type { DescriptorStats } from "../audioattributes.ts";
13
+ import {
14
+ audioSeed,
15
+ audioSeedFromStats,
16
+ DESCRIPTOR_DIMS,
17
+ DIM_ORDER,
18
+ descriptorSummary,
19
+ fuseDescriptor,
20
+ normalize01,
21
+ zcurve,
22
+ } from "../audiofeatures.ts";
23
+
24
+ /** one believable mid-tempo minor-key clip, all fields in documented units. */
25
+ const baseStats: DescriptorStats = {
26
+ spectral: {
27
+ centroidMean: 2200,
28
+ centroidStd: 900,
29
+ rolloffMean: 7200,
30
+ flatnessMean: 0.12,
31
+ flatnessStd: 0.05,
32
+ fluxMean: 0.08,
33
+ fluxStd: 0.04,
34
+ bandBalance: [0.5, 0.6, 0.7, 0.6, 0.4, 0.2, 0.1],
35
+ brightnessIndex: 0.55,
36
+ },
37
+ rhythm: {
38
+ bpm: 124,
39
+ confidence: 0.82,
40
+ onsetsPerSecond: 3.4,
41
+ regularityIndex: 0.71,
42
+ swing: { ratio: 0.58, swung: true },
43
+ downbeatPeriodBeats: 4,
44
+ },
45
+ harmonic: {
46
+ key: { tonic: 9, mode: "minor", strength: 0.64 },
47
+ 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],
48
+ harmonicChangeRate: 0.31,
49
+ dissonanceIndex: 0.22,
50
+ },
51
+ timbre: {
52
+ noisiness: 0.34,
53
+ warmth: 0.61,
54
+ dynamics: { rangeDb: 18.5, crestFactor: 8.2 },
55
+ brightness: 0.58,
56
+ slopeMean: -5.5,
57
+ zcrMean: 0.11,
58
+ },
59
+ structure: {
60
+ durationMs: 213000,
61
+ peakSectionMs: 24000,
62
+ repetitionIndex: 0.42,
63
+ narrativeShape: "arch",
64
+ sectionCount: 6,
65
+ },
66
+ };
67
+
68
+ /** every field broken at once — NaN, infinities, wrong literals, short arrays. */
69
+ const damagedStats = {
70
+ spectral: {
71
+ centroidMean: NaN,
72
+ centroidStd: Infinity,
73
+ rolloffMean: NaN,
74
+ flatnessMean: NaN,
75
+ flatnessStd: NaN,
76
+ fluxMean: -5,
77
+ fluxStd: 99,
78
+ bandBalance: [NaN, NaN, NaN],
79
+ brightnessIndex: NaN,
80
+ },
81
+ rhythm: {
82
+ bpm: NaN,
83
+ confidence: NaN,
84
+ onsetsPerSecond: NaN,
85
+ regularityIndex: NaN,
86
+ swing: { ratio: NaN, swung: "yes" },
87
+ downbeatPeriodBeats: NaN,
88
+ },
89
+ harmonic: {
90
+ key: { tonic: NaN, mode: "dorian", strength: NaN },
91
+ chromaEnergy: [NaN],
92
+ harmonicChangeRate: NaN,
93
+ dissonanceIndex: NaN,
94
+ },
95
+ timbre: {
96
+ noisiness: NaN,
97
+ warmth: NaN,
98
+ dynamics: { rangeDb: NaN, crestFactor: NaN },
99
+ brightness: NaN,
100
+ slopeMean: NaN,
101
+ zcrMean: NaN,
102
+ },
103
+ structure: {
104
+ durationMs: NaN,
105
+ peakSectionMs: NaN,
106
+ repetitionIndex: NaN,
107
+ narrativeShape: "crescendo",
108
+ sectionCount: NaN,
109
+ },
110
+ } as unknown as DescriptorStats;
111
+
112
+ /** 0.1 s of 440 hz sine at 44.1 khz — stable deterministic fixture. */
113
+ function sine440(): Float32Array {
114
+ const out = new Float32Array(8820);
115
+ for (let i = 0; i < out.length; i += 1) out[i] = 0.5 * Math.sin((2 * Math.PI * 440 * i) / 44100);
116
+ return out;
117
+ }
118
+
119
+ /** 0.1 s of lcg noise — clearly different bits from the sine fixture. */
120
+ function noiseburst(): Float32Array {
121
+ const out = new Float32Array(8820);
122
+ let state = 123456789;
123
+ for (let i = 0; i < out.length; i += 1) {
124
+ state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
125
+ out[i] = (state / 2147483648 - 1) * 0.5;
126
+ }
127
+ return out;
128
+ }
129
+
130
+ describe("audiofeatures vector", () => {
131
+ it("fuses exactly 32 dims in the documented order", () => {
132
+ const d = fuseDescriptor(baseStats);
133
+ assert.equal(DIM_ORDER.length, DESCRIPTOR_DIMS);
134
+ assert.equal(d.vector.length, DESCRIPTOR_DIMS);
135
+ assert.equal(d.version, 1);
136
+ // spot-check the documented order at the group edges
137
+ assert.equal(d.vector[0], normalize01(2200, 0, 8000)); // spectral centroid
138
+ assert.equal(d.vector[6], 0.55); // brightness passes through
139
+ assert.equal(d.vector[7], zcurve(124, 110, 40)); // tempo
140
+ assert.equal(d.vector[21], normalize01(18.5, 0, 60)); // dynamics contrast
141
+ assert.equal(d.vector[31], 0.64); // mood shadow = key strength × minor
142
+ });
143
+ it("keeps every dim finite and clamped to 0-1", () => {
144
+ const d = fuseDescriptor(baseStats);
145
+ for (const v of d.vector) {
146
+ assert.ok(Number.isFinite(v));
147
+ assert.ok(v >= 0 && v <= 1);
148
+ }
149
+ });
150
+ it("folds the band balance into the cross dims", () => {
151
+ const low = fuseDescriptor({
152
+ ...baseStats,
153
+ spectral: { ...baseStats.spectral, bandBalance: [1, 0.9, 0.2, 0.1, 0, 0, 0] },
154
+ });
155
+ const high = fuseDescriptor({
156
+ ...baseStats,
157
+ spectral: { ...baseStats.spectral, bandBalance: [0, 0, 0, 0.1, 0.2, 0.9, 1] },
158
+ });
159
+ assert.ok(low.vector[30] > 0.5); // band tilt leans dark
160
+ assert.ok(high.vector[30] < 0.5); // band tilt leans bright
161
+ });
162
+ it("marks the mood shadow only on a minor key", () => {
163
+ const minor = fuseDescriptor(baseStats);
164
+ const major = fuseDescriptor({
165
+ ...baseStats,
166
+ harmonic: { ...baseStats.harmonic, key: { ...baseStats.harmonic.key, mode: "major" } },
167
+ });
168
+ assert.equal(minor.scalar.minor, 1);
169
+ assert.ok(minor.vector[31] > 0);
170
+ assert.equal(major.vector[31], 0);
171
+ });
172
+ });
173
+
174
+ describe("audiofeatures determinism", () => {
175
+ it("answers a deep-equal descriptor for the same stats twice", () => {
176
+ assert.deepEqual(fuseDescriptor(baseStats), fuseDescriptor(baseStats));
177
+ assert.equal(fuseDescriptor(baseStats).seed, audioSeedFromStats(baseStats));
178
+ });
179
+ it("stays stable and frozen at version 1", () => {
180
+ const d = fuseDescriptor(baseStats);
181
+ assert.equal(d.version, 1);
182
+ assert.ok(Object.isFrozen(d));
183
+ assert.ok(Object.isFrozen(d.vector));
184
+ assert.ok(Object.isFrozen(d.scalar));
185
+ });
186
+ it("seeds identically from the same pcm fixture", () => {
187
+ assert.equal(audioSeed(sine440()), audioSeed(sine440()));
188
+ assert.match(audioSeed(sine440()), /^[0-9a-f]{16}$/);
189
+ });
190
+ it("seeds differently for clearly different pcm fixtures", () => {
191
+ assert.notEqual(audioSeed(sine440()), audioSeed(noiseburst()));
192
+ });
193
+ it("seeds differently for different stats and never for identical ones", () => {
194
+ const a = audioSeedFromStats(baseStats);
195
+ const b = audioSeedFromStats({ ...baseStats, rhythm: { ...baseStats.rhythm, bpm: 126 } });
196
+ assert.equal(a, audioSeedFromStats(baseStats));
197
+ assert.notEqual(a, b);
198
+ assert.match(b, /^[0-9a-f]{16}$/);
199
+ });
200
+ it("survives empty, tiny and non-pcm input with a stable seed", () => {
201
+ const empty = audioSeed(new Float32Array(0));
202
+ assert.match(empty, /^[0-9a-f]{16}$/);
203
+ assert.equal(empty, audioSeed(new Float32Array(0)));
204
+ assert.notEqual(empty, audioSeed(new Float32Array(1))); // length folds in
205
+ assert.equal(audioSeed(undefined as unknown as Float32Array), empty);
206
+ });
207
+ });
208
+
209
+ describe("audiofeatures monotonic mappings", () => {
210
+ it("maps a wider dynamics range to a higher contrast dim (21)", () => {
211
+ const loud = fuseDescriptor({
212
+ ...baseStats,
213
+ timbre: { ...baseStats.timbre, dynamics: { rangeDb: 50, crestFactor: 8.2 } },
214
+ });
215
+ const quiet = fuseDescriptor({
216
+ ...baseStats,
217
+ timbre: { ...baseStats.timbre, dynamics: { rangeDb: 10, crestFactor: 8.2 } },
218
+ });
219
+ assert.ok(loud.vector[21] > quiet.vector[21]);
220
+ });
221
+ it("maps a faster bpm to a higher tempo dim (7)", () => {
222
+ const fast = fuseDescriptor({ ...baseStats, rhythm: { ...baseStats.rhythm, bpm: 140 } });
223
+ const slow = fuseDescriptor({ ...baseStats, rhythm: { ...baseStats.rhythm, bpm: 90 } });
224
+ assert.ok(fast.vector[7] > slow.vector[7]);
225
+ });
226
+ it("keeps zcurve monotonic across the tempo field", () => {
227
+ const a = zcurve(60, 110, 40);
228
+ const b = zcurve(110, 110, 40);
229
+ const c = zcurve(200, 110, 40);
230
+ assert.ok(a < b && b < c);
231
+ });
232
+ });
233
+
234
+ describe("audiofeatures helpers", () => {
235
+ it("clamps normalize01 on both edges, degenerate ranges and NaN", () => {
236
+ assert.equal(normalize01(-1, 0, 1), 0);
237
+ assert.equal(normalize01(2, 0, 1), 1);
238
+ assert.equal(normalize01(5, 0, 10), 0.5);
239
+ assert.equal(normalize01(NaN, 0, 1), 0);
240
+ assert.equal(normalize01(0.5, 1, 0), 0);
241
+ });
242
+ it("answers a lowercase summary that names key and bpm", () => {
243
+ const text = descriptorSummary(fuseDescriptor(baseStats));
244
+ assert.ok(text.includes("key"));
245
+ assert.ok(text.includes("bpm"));
246
+ assert.ok(text.includes("arch"));
247
+ assert.equal(text, text.toLowerCase());
248
+ });
249
+ });
250
+
251
+ describe("audiofeatures damage control", () => {
252
+ it("coerces NaN-riddled stats without emitting NaN anywhere", () => {
253
+ const d = fuseDescriptor(damagedStats);
254
+ assert.equal(d.vector.length, DESCRIPTOR_DIMS);
255
+ for (const v of d.vector) {
256
+ assert.ok(Number.isFinite(v));
257
+ assert.ok(v >= 0 && v <= 1);
258
+ }
259
+ for (const v of Object.values(d.scalar)) {
260
+ assert.ok(Number.isFinite(v));
261
+ assert.ok(v >= 0 && v <= 1);
262
+ }
263
+ assert.match(d.seed, /^[0-9a-f]{16}$/);
264
+ assert.match(descriptorSummary(d), /^[a-z]/); // still readable, lowercase
265
+ });
266
+ it("honors an explicit pcm seed override when the audio is at hand", () => {
267
+ const d = fuseDescriptor(baseStats, audioSeed(sine440()));
268
+ assert.equal(d.seed, audioSeed(sine440()));
269
+ assert.match(d.seed, /^[0-9a-f]{16}$/);
270
+ });
271
+ });
@@ -0,0 +1,193 @@
1
+ // # audiopipeline.test — deterministic end-to-end tests for the wave-1
2
+ // orchestrator, runnable with vitest:
3
+ // pnpm vitest run tests/audiopipeline.test.ts
4
+ // The fixture synthesizes a 4-second 4-phase song (saw intro, hats, c-major
5
+ // triad, decaying pad) straight into pcm — no binary files on disk.
6
+
7
+ import assert from "node:assert/strict";
8
+ import { describe, it } from "vitest";
9
+ import { decodeWav } from "../audiodecode.ts";
10
+ import { analyzePcm, analyzeWavBytes, PipelineError, reportDigest } from "../audiopipeline.ts";
11
+
12
+ const SAMPLE_RATE = 48000;
13
+
14
+ /** appends a sine/saw tone with the given gain envelope into the track. */
15
+ function tone(
16
+ track: Float32Array,
17
+ sampleRate: number,
18
+ startMs: number,
19
+ durationMs: number,
20
+ hz: number,
21
+ gain = 0.6,
22
+ kind: "sine" | "saw" = "sine",
23
+ ): void {
24
+ const start = Math.round((startMs / 1000) * sampleRate);
25
+ const length = Math.round((durationMs / 1000) * sampleRate);
26
+ for (let i = 0; i < length && start + i < track.length; i++) {
27
+ const phase = (hz * i) / sampleRate;
28
+ const value = kind === "saw" ? 2 * (phase - Math.floor(phase + 0.5)) : Math.sin(2 * Math.PI * phase);
29
+ const fade = i < length * 0.1 ? i / (length * 0.1) : i > length * 0.85 ? (length - i) / (length * 0.15) : 1;
30
+ track[start + i] += gain * value * fade;
31
+ }
32
+ }
33
+
34
+ /** the shared 4-second song fixture: a steady 120bpm kick spine under an
35
+ * intro saw, hats, triad and falling pad — the kick keeps the tempo band
36
+ * decisive for both the pcm and the quantized wav path. */
37
+ function songFixture(): Float32Array {
38
+ const track = new Float32Array(4 * SAMPLE_RATE);
39
+ for (let t = 0; t < 4000; t += 500) tone(track, SAMPLE_RATE, t, 120, 60, 0.7);
40
+ tone(track, SAMPLE_RATE, 0, 1000, 110, 0.5, "saw");
41
+ for (const hz of [261.63, 329.63, 392.0]) tone(track, SAMPLE_RATE, 2000, 1000, hz, 0.4);
42
+ for (const [i, hz] of [220, 196, 174.61].entries())
43
+ tone(track, SAMPLE_RATE, 2000 + i * 120, 1000 - i * 120, hz, 0.45 - i * 0.12);
44
+ return track;
45
+ }
46
+
47
+ /** wraps pcm into a minimal 16-bit mono wav byte buffer (RIFF header). */
48
+ function wavBytes(samples: Float32Array, sampleRate: number): Uint8Array {
49
+ const pcm = new Int16Array(samples.length);
50
+ for (let i = 0; i < samples.length; i++) {
51
+ const clamped = Math.max(-1, Math.min(1, samples[i]));
52
+ pcm[i] = Math.round(clamped * 32767);
53
+ }
54
+ const bytes = new Uint8Array(44 + pcm.length * 2);
55
+ const view = new DataView(bytes.buffer);
56
+ const text = (offset: number, value: string): void => {
57
+ for (let i = 0; i < value.length; i++) view.setUint8(offset + i, value.charCodeAt(i));
58
+ };
59
+ text(0, "RIFF");
60
+ view.setUint32(4, 36 + pcm.length * 2, true);
61
+ text(8, "WAVE");
62
+ text(12, "fmt ");
63
+ view.setUint32(16, 16, true);
64
+ view.setUint16(20, 1, true);
65
+ view.setUint16(22, 1, true);
66
+ view.setUint32(24, sampleRate, true);
67
+ view.setUint32(28, sampleRate * 2, true);
68
+ view.setUint16(32, 2, true);
69
+ view.setUint16(34, 16, true);
70
+ text(36, "data");
71
+ view.setUint32(40, pcm.length * 2, true);
72
+ bytes.set(new Uint8Array(pcm.buffer), 44);
73
+ return bytes;
74
+ }
75
+
76
+ describe("analysis report", () => {
77
+ const song = songFixture();
78
+ const report = analyzePcm(song, SAMPLE_RATE);
79
+
80
+ it("carries schemaVersion 1 and a sane source block", () => {
81
+ assert.equal(report.schemaVersion, 1);
82
+ assert.equal(report.source.kind, "pcm");
83
+ assert.equal(report.source.channels, 1);
84
+ assert.ok(Math.abs(report.source.durationMs - 4000) < 60, `duration ${report.source.durationMs}`);
85
+ assert.equal(report.source.sampleRate, 22050);
86
+ });
87
+ it("answers every analysis block", () => {
88
+ for (const block of ["spectrum", "rhythm", "harmonic", "timbre", "structure", "descriptor"] as const) {
89
+ assert.ok(report[block] != null, `missing block ${block}`);
90
+ }
91
+ });
92
+ it("reads a sane tempo band", () => {
93
+ assert.ok(report.rhythm.rawBpm >= 100 && report.rhythm.rawBpm <= 145, `bpm ${report.rhythm.rawBpm}`);
94
+ assert.ok(report.rhythm.confidence > 0.1, `confidence ${report.rhythm.confidence}`);
95
+ });
96
+ it("keys the triad section with strength", () => {
97
+ assert.ok(report.harmonic.key.strength > 0, `strength ${report.harmonic.key.strength}`);
98
+ assert.ok(report.harmonic.key.tonic >= 0 && report.harmonic.key.tonic <= 11);
99
+ });
100
+ it("fuses a 32-dim descriptor clamped to 0-1", () => {
101
+ assert.equal(report.descriptor.vector.length, 32);
102
+ for (const dim of report.descriptor.vector) assert.ok(Number.isFinite(dim) && dim >= 0 && dim <= 1, `dim ${dim}`);
103
+ assert.equal(report.descriptor.version, 1);
104
+ });
105
+ it("sections the four-phase fixture in order", () => {
106
+ assert.ok(report.structure.sectionCount >= 2, `sections ${report.structure.sectionCount}`);
107
+ assert.ok(
108
+ report.structure.durationMs > 0 && Math.abs(report.structure.durationMs - report.source.durationMs) < 120,
109
+ `structure duration ${report.structure.durationMs} vs source ${report.source.durationMs}`,
110
+ );
111
+ assert.ok(["arch", "rise", "fall", "wave", "flat"].includes(report.structure.narrativeShape));
112
+ });
113
+ it("is deterministic apart from the timings", () => {
114
+ const again = analyzePcm(song, SAMPLE_RATE);
115
+ const strip = (r: typeof report): Omit<typeof r, "timings"> => {
116
+ const { timings: _timings, ...rest } = r;
117
+ return rest;
118
+ };
119
+ assert.deepEqual(strip(again), strip(report));
120
+ });
121
+ it("survives a json round trip with the same shape", () => {
122
+ const restored = JSON.parse(JSON.stringify(report));
123
+ assert.equal(restored.schemaVersion, 1);
124
+ assert.equal(restored.descriptor.vector.length, 32);
125
+ assert.equal(restored.structure.sectionCount, report.structure.sectionCount);
126
+ });
127
+ });
128
+
129
+ describe("wav path", () => {
130
+ const song = songFixture();
131
+ it("agrees with the pcm path modulo container quantization", () => {
132
+ const fromBytes = analyzeWavBytes(wavBytes(song, SAMPLE_RATE), { analysisSampleRate: 22050 });
133
+ const fromPcm = analyzePcm(song, SAMPLE_RATE, 1, { analysisSampleRate: 22050 });
134
+ // the wav container quantizes to 16 bits: each path stays bit-deterministic
135
+ // on its own input (covered by the determinism test), and across paths the
136
+ // stable stats agree — tempo within two bpm — while onset-sensitive dims
137
+ // (swing, regularity) may flip under the quantization staircase, so the
138
+ // whole vector only needs to stay close on average.
139
+ assert.ok(
140
+ Math.abs(fromBytes.rhythm.rawBpm - fromPcm.rhythm.rawBpm) < 2,
141
+ `bpm ${fromBytes.rhythm.rawBpm} vs ${fromPcm.rhythm.rawBpm}`,
142
+ );
143
+ let sum = 0;
144
+ for (let i = 0; i < fromPcm.descriptor.vector.length; i++)
145
+ sum += Math.abs(fromBytes.descriptor.vector[i] - fromPcm.descriptor.vector[i]);
146
+ const meanDiff = sum / fromPcm.descriptor.vector.length;
147
+ assert.ok(meanDiff < 0.15, `mean dim diff ${meanDiff.toFixed(4)} too high`);
148
+ assert.equal(fromBytes.source.kind, "wav");
149
+ });
150
+ it("round trips through the decode layer first", () => {
151
+ const bytes = wavBytes(song, SAMPLE_RATE);
152
+ const decoded = decodeWav(bytes, { targetSampleRate: 22050, mono: true });
153
+ assert.ok(decoded.length > 0);
154
+ assert.equal(decoded.channels, 1);
155
+ });
156
+ });
157
+
158
+ describe("error taxonomy", () => {
159
+ it("refuses an empty slice", () => {
160
+ assert.throws(
161
+ () => analyzePcm(new Float32Array(0), 48000),
162
+ (error: unknown) => error instanceof PipelineError && error.code === "pipeline-empty-samples",
163
+ );
164
+ });
165
+ it("refuses a slice shorter than one second", () => {
166
+ assert.throws(
167
+ () => analyzePcm(new Float32Array(22050 / 2), 22050),
168
+ (error: unknown) => error instanceof PipelineError && error.code === "pipeline-too-short",
169
+ );
170
+ });
171
+ it("refuses a non-finite sample rate", () => {
172
+ assert.throws(
173
+ () => analyzePcm(new Float32Array(48000), NaN),
174
+ (error: unknown) => error instanceof PipelineError && error.code === "pipeline-unsupported-sample-rate",
175
+ );
176
+ });
177
+ it("refuses empty wav bytes", () => {
178
+ assert.throws(
179
+ () => analyzeWavBytes(new Uint8Array(0)),
180
+ (error: unknown) => error instanceof PipelineError && error.code === "pipeline-empty-samples",
181
+ );
182
+ });
183
+ });
184
+
185
+ describe("report digest", () => {
186
+ it("names bpm, key, shape and seed in lowercase", () => {
187
+ const digest = reportDigest(analyzePcm(songFixture(), SAMPLE_RATE));
188
+ assert.match(digest, /bpm \d+/);
189
+ assert.match(digest, / (major|minor) /);
190
+ assert.match(digest, /seed [0-9a-f]+/);
191
+ assert.equal(digest, digest.toLowerCase());
192
+ });
193
+ });
@@ -0,0 +1,172 @@
1
+ // # audiorhythm.test — deterministic unit tests for the cadria rhythm layer,
2
+ // runnable with vitest:
3
+ // pnpm vitest run tests/audiorhythm.test.ts
4
+ // Fixtures synthesize click tracks, swings and jitter straight into PCM —
5
+ // no binary files, no randomness.
6
+
7
+ import assert from "node:assert/strict";
8
+ import { describe, it } from "vitest";
9
+ import { onsetEnvelope, onsetPeaks, rhythmStats } from "../audiorhythm.ts";
10
+ import { canonicalTempo, RhythmError } from "../audiorhythmbeat.ts";
11
+
12
+ /** builds a mono sine burst at hz for ms on a track at sampleRate. */
13
+ function burst(
14
+ track: Float32Array,
15
+ sampleRate: number,
16
+ startMs: number,
17
+ durationMs: number,
18
+ hz: number,
19
+ gain = 0.8,
20
+ ): void {
21
+ const start = Math.round((startMs / 1000) * sampleRate);
22
+ const length = Math.round((durationMs / 1000) * sampleRate);
23
+ for (let i = 0; i < length && start + i < track.length; i++) {
24
+ track[start + i] += gain * Math.sin((2 * Math.PI * hz * i) / sampleRate);
25
+ }
26
+ }
27
+
28
+ /** synthesizes a click track: short 1200Hz bursts every `periodMs`. */
29
+ function clickTrack(sampleRate: number, durationMs: number, periodMs: number, clickMs = 18): Float32Array {
30
+ const track = new Float32Array(Math.round((durationMs / 1000) * sampleRate));
31
+ for (let t = 0; t < durationMs; t += periodMs) burst(track, sampleRate, t, clickMs, 1200);
32
+ return track;
33
+ }
34
+
35
+ describe("onset envelope", () => {
36
+ const sampleRate = 22050;
37
+ it("answers zero flux for silence", () => {
38
+ const { envelope } = onsetEnvelope(new Float32Array(sampleRate * 2), sampleRate);
39
+ let max = 0;
40
+ for (const v of envelope) if (v > max) max = v;
41
+ assert.ok(max === 0, `silence flux must be 0, got ${max}`);
42
+ });
43
+ it("fires peaks on a click track and stays near zero between", () => {
44
+ const track = clickTrack(sampleRate, 4000, 500);
45
+ const { envelope, frameMs } = onsetEnvelope(track, sampleRate);
46
+ assert.ok(frameMs > 0);
47
+ let peaks = 0;
48
+ for (let i = 1; i < envelope.length - 1; i++)
49
+ if (envelope[i] > envelope[i - 1] && envelope[i] >= envelope[i + 1] && envelope[i] > 0.05) peaks++;
50
+ assert.ok(peaks >= 6, `expected at least 6 flux peaks for 7 clicks, got ${peaks}`);
51
+ });
52
+ it("keeps frameMs tied to the hop and sample rate", () => {
53
+ const { frameMs } = onsetEnvelope(clickTrack(sampleRate, 1000, 250), sampleRate, { size: 1024, hop: 256 });
54
+ assert.ok(Math.abs(frameMs - (256 / sampleRate) * 1000) < 1e-9);
55
+ });
56
+ it("throws RhythmError for a bad sample rate", () => {
57
+ assert.throws(() => onsetEnvelope(new Float32Array(1024), 0), RhythmError);
58
+ });
59
+ it("is deterministic", () => {
60
+ const track = clickTrack(sampleRate, 2000, 333);
61
+ const a = onsetEnvelope(track, sampleRate);
62
+ const b = onsetEnvelope(track, sampleRate);
63
+ assert.deepEqual([...b.envelope], [...a.envelope]);
64
+ });
65
+ });
66
+
67
+ describe("onset peaks", () => {
68
+ const sampleRate = 22050;
69
+ it("finds near-exact counts on an impulse track", () => {
70
+ const track = clickTrack(sampleRate, 4000, 500);
71
+ const { envelope, frameMs } = onsetEnvelope(track, sampleRate);
72
+ const onsets = onsetPeaks(envelope, frameMs);
73
+ // the t=0 click sits inside the first stft frame — spectral flux has no
74
+ // previous frame to diff against, so detection starts at the second click.
75
+ assert.ok(onsets.length >= 5 && onsets.length <= 7, `expected 5-7 onsets, got ${onsets.length}`);
76
+ assert.ok(Math.abs(onsets[0] - 500) < 80, `first detected onset near 500ms, got ${onsets[0]}`);
77
+ });
78
+ it("answers no peaks on silence", () => {
79
+ const { envelope, frameMs } = onsetEnvelope(new Float32Array(sampleRate), sampleRate);
80
+ assert.equal(onsetPeaks(envelope, frameMs).length, 0);
81
+ });
82
+ it("respects the minimum gap", () => {
83
+ const track = clickTrack(sampleRate, 2000, 90);
84
+ const { envelope, frameMs } = onsetEnvelope(track, sampleRate);
85
+ const onsets = onsetPeaks(envelope, frameMs, { minGapMs: 200 });
86
+ for (let i = 1; i < onsets.length; i++)
87
+ assert.ok(onsets[i] - onsets[i - 1] >= 200 - 1e-9, `gap ${onsets[i] - onsets[i - 1]} below minGapMs`);
88
+ });
89
+ it("returns ascending times", () => {
90
+ const track = clickTrack(sampleRate, 3000, 400);
91
+ const { envelope, frameMs } = onsetEnvelope(track, sampleRate);
92
+ const onsets = onsetPeaks(envelope, frameMs);
93
+ for (let i = 1; i < onsets.length; i++) assert.ok(onsets[i] > onsets[i - 1]);
94
+ });
95
+ });
96
+
97
+ describe("tempo estimate via stats", () => {
98
+ const sampleRate = 22050;
99
+ it("reads a 120 bpm click track at 118-122", () => {
100
+ const stats = rhythmStats(clickTrack(sampleRate, 8000, 500), sampleRate);
101
+ assert.ok(stats.rawBpm >= 118 && stats.rawBpm <= 122, `bpm ${stats.rawBpm} outside 118-122`);
102
+ assert.equal(stats.multiplier, 1);
103
+ assert.ok(stats.regularityIndex > 0.6, `regularity ${stats.regularityIndex} too low`);
104
+ });
105
+ it("canonicalizes 180 bpm into the 70-160 fold", () => {
106
+ const folded = canonicalTempo(180);
107
+ assert.equal(folded.bpm, 90);
108
+ assert.equal(folded.multiplier, 0.5);
109
+ });
110
+ it("reads a 90 bpm track near 90", () => {
111
+ const stats = rhythmStats(clickTrack(sampleRate, 8000, 666.6667), sampleRate);
112
+ assert.ok(stats.bpm >= 86 && stats.bpm <= 94, `bpm ${stats.bpm} outside 86-94`);
113
+ });
114
+ it("answers the zero band for silence without throwing", () => {
115
+ const stats = rhythmStats(new Float32Array(sampleRate * 3), sampleRate);
116
+ assert.equal(stats.bpm, 0);
117
+ assert.equal(stats.confidence, 0);
118
+ assert.equal(stats.onsetsPerSecond, 0);
119
+ assert.deepEqual(stats.swing, { ratio: 0.5, swung: false });
120
+ });
121
+ it("keeps regularity of a metronome above a jittered track", () => {
122
+ const clean = rhythmStats(clickTrack(sampleRate, 6000, 500), sampleRate);
123
+ const jittered = clickTrack(sampleRate, 6000, 500);
124
+ for (let t = 0, k = 0; t < 6000; t += 500, k++) {
125
+ const drift = (k % 2 === 0 ? 28 : -31) * (1 + (k % 3) / 4);
126
+ burst(jittered, sampleRate, Math.max(0, t + drift), 18, 1200, 0.5);
127
+ }
128
+ const rough = rhythmStats(jittered, sampleRate);
129
+ assert.ok(
130
+ clean.regularityIndex > rough.regularityIndex,
131
+ `clean ${clean.regularityIndex} must beat jitter ${rough.regularityIndex}`,
132
+ );
133
+ });
134
+ it("reports a swung track as swung", () => {
135
+ const sampleRate2 = 22050;
136
+ const track = new Float32Array(Math.round(4 * sampleRate2));
137
+ for (let beat = 0; beat < 8; beat++) {
138
+ const base = beat * 500;
139
+ burst(track, sampleRate2, base, 15, 1200);
140
+ burst(track, sampleRate2, base + 340, 12, 900, 0.5);
141
+ }
142
+ const stats = rhythmStats(track, sampleRate2);
143
+ assert.ok(stats.swing.ratio > 0.58, `ratio ${stats.swing.ratio} below the swing threshold`);
144
+ assert.equal(stats.swing.swung, true);
145
+ });
146
+ });
147
+
148
+ describe("stats hygiene", () => {
149
+ const sampleRate = 22050;
150
+ it("is deterministic end to end", () => {
151
+ const track = clickTrack(sampleRate, 5000, 428.57);
152
+ assert.deepEqual(rhythmStats(track, sampleRate), rhythmStats(track, sampleRate));
153
+ });
154
+ it("carries only finite numbers", () => {
155
+ const stats = rhythmStats(clickTrack(sampleRate, 4000, 500), sampleRate);
156
+ const walk = (value: unknown): void => {
157
+ if (typeof value === "number") assert.ok(Number.isFinite(value), `non-finite ${value}`);
158
+ else if (value !== null && typeof value === "object") for (const child of Object.values(value)) walk(child);
159
+ };
160
+ walk(stats);
161
+ });
162
+ it("clamps onsetsPerSecond to a sane band", () => {
163
+ const stats = rhythmStats(clickTrack(sampleRate, 4000, 500), sampleRate);
164
+ assert.ok(
165
+ stats.onsetsPerSecond > 0 && stats.onsetsPerSecond < 20,
166
+ `onsets/s ${stats.onsetsPerSecond} outside sanity`,
167
+ );
168
+ });
169
+ it("throws for a non-finite sample rate", () => {
170
+ assert.throws(() => rhythmStats(new Float32Array(1024), NaN), RhythmError);
171
+ });
172
+ });