@wenathlan/cadria 2.0.94 → 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 +14 -5
  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 -4
  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,257 @@
1
+ // # audiospectrum.test — honest unit tests for the spectral layer, runnable
2
+ // with vitest:
3
+ // pnpm vitest run tests/audiospectrum.test.ts
4
+ // Fixtures are synthetic: pure sines on exact bins, DC, seeded noise and a
5
+ // two-tone onset signal — every test is deterministic.
6
+ import assert from "node:assert/strict";
7
+ import { describe, it } from "vitest";
8
+ import {
9
+ blackman,
10
+ FftError,
11
+ fft,
12
+ fftMagnitudes,
13
+ hamming,
14
+ hann,
15
+ stft,
16
+ stftFrameCount,
17
+ type WindowName,
18
+ } from "../audiofft.ts";
19
+ import {
20
+ BAND_EDGES_HZ,
21
+ BAND_NAMES,
22
+ bandEnergies,
23
+ hertzToMel,
24
+ melbands,
25
+ melToHertz,
26
+ spectralCentroid,
27
+ spectralFlatness,
28
+ spectralFlux,
29
+ spectralRolloff,
30
+ spectrogramImage,
31
+ summarizeSpectrum,
32
+ } from "../audiospectrum.ts";
33
+
34
+ const SAMPLE_RATE = 8000;
35
+ const SIZE = 1024;
36
+ const STEP = SAMPLE_RATE / SIZE; // 7.8125 Hz per bin
37
+
38
+ /** one frame of a pure sine completing `cycles` periods inside the frame. */
39
+ function sineFrame(cycles: number, size = SIZE): Float32Array {
40
+ const out = new Float32Array(size);
41
+ for (let i = 0; i < size; i++) out[i] = Math.sin((2 * Math.PI * cycles * i) / size);
42
+ return out;
43
+ }
44
+
45
+ /** deterministic seeded noise (LCG), values in [-1, 1). */
46
+ function noiseFrame(size: number, seed: number): Float32Array {
47
+ let state = seed >>> 0;
48
+ const out = new Float32Array(size);
49
+ for (let i = 0; i < size; i++) {
50
+ state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
51
+ out[i] = (state / 4294967296) * 2 - 1;
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /** bin index of the largest magnitude. */
57
+ function peakBin(magnitudes: Float32Array): number {
58
+ let best = 0;
59
+ for (let k = 1; k < magnitudes.length; k++) if (magnitudes[k] > magnitudes[best]) best = k;
60
+ return best;
61
+ }
62
+
63
+ /** sum of a typed array. */
64
+ function total(values: ArrayLike<number>): number {
65
+ let acc = 0;
66
+ for (let i = 0; i < values.length; i++) acc += values[i];
67
+ return acc;
68
+ }
69
+
70
+ describe("audiofft", () => {
71
+ it("peaks a pure sine at the expected bin", () => {
72
+ const magnitudes = fftMagnitudes(sineFrame(8));
73
+ assert.equal(magnitudes.length, SIZE / 2 + 1);
74
+ const peak = peakBin(magnitudes);
75
+ assert.ok(Math.abs(peak - 8) <= 1, `peak at bin ${peak}, expected 8 ± 1`);
76
+ });
77
+
78
+ it("keeps DC energy at bin 0", () => {
79
+ const magnitudes = fftMagnitudes(new Float32Array(256).fill(1));
80
+ assert.ok(Math.abs(magnitudes[0] - 256) < 1e-3, `DC bin ${magnitudes[0]}`);
81
+ assert.equal(peakBin(magnitudes), 0);
82
+ assert.ok(total(magnitudes) - magnitudes[0] < 1e-2, "no energy outside bin 0");
83
+ });
84
+
85
+ it("conserves energy (Parseval)", () => {
86
+ const samples = noiseFrame(SIZE, 7);
87
+ const re = Float32Array.from(samples);
88
+ const im = new Float32Array(SIZE);
89
+ fft(re, im);
90
+ let timeEnergy = 0;
91
+ for (let i = 0; i < SIZE; i++) timeEnergy += samples[i] * samples[i];
92
+ let spectralEnergy = 0;
93
+ for (let k = 0; k < SIZE; k++) spectralEnergy += re[k] * re[k] + im[k] * im[k];
94
+ const ratio = spectralEnergy / (SIZE * timeEnergy);
95
+ assert.ok(Math.abs(ratio - 1) < 1e-4, `Parseval ratio ${ratio}`);
96
+ });
97
+
98
+ it("sums periodic windows to their documented totals", () => {
99
+ assert.ok(Math.abs(total(hann(SIZE)) - SIZE / 2) < 1e-6, `hann sums to ${total(hann(SIZE))}`);
100
+ assert.ok(Math.abs(total(hamming(SIZE)) - 0.54 * SIZE) < 1e-6);
101
+ assert.ok(Math.abs(total(blackman(SIZE)) - 0.42 * SIZE) < 1e-6);
102
+ assert.equal(hann(SIZE)[0], 0);
103
+ });
104
+
105
+ it("counts stft frames as ceil((n − size) / hop) + 1", () => {
106
+ assert.equal(stftFrameCount(5000, 1024, 300), Math.ceil((5000 - 1024) / 300) + 1);
107
+ assert.equal(stft(new Float32Array(5000), { size: 1024, hop: 300 }).length, 15);
108
+ assert.equal(stftFrameCount(200, 1024, 512), 0);
109
+ assert.equal(stft(new Float32Array(200), { size: 1024 }).length, 0);
110
+ });
111
+
112
+ it("returns one-sided magnitude frames, deterministically", () => {
113
+ const samples = noiseFrame(4096, 21);
114
+ const first = stft(samples, { size: 512, hop: 256, window: "hamming" });
115
+ const again = stft(samples, { size: 512, hop: 256, window: "hamming" });
116
+ assert.equal(first.length, stftFrameCount(4096, 512, 256));
117
+ for (let f = 0; f < first.length; f++) {
118
+ assert.equal(first[f].length, 512 / 2 + 1);
119
+ assert.deepEqual(first[f], again[f]);
120
+ for (const value of first[f]) assert.ok(Number.isFinite(value));
121
+ }
122
+ });
123
+
124
+ it("rejects invalid sizes and unknown windows", () => {
125
+ assert.throws(() => fft(new Float32Array(100), new Float32Array(100)), FftError);
126
+ assert.throws(() => fftMagnitudes(new Float32Array(32768)), FftError);
127
+ assert.throws(() => stft(new Float32Array(4096), { window: "kaiser" as WindowName }), FftError);
128
+ });
129
+ });
130
+
131
+ describe("audiospectrum", () => {
132
+ it("round-trips mel and hertz", () => {
133
+ assert.equal(melToHertz(0), 0);
134
+ assert.ok(Math.abs(hertzToMel(1000) - 1000) < 1, `1000 Hz is ${hertzToMel(1000)} mel`);
135
+ assert.ok(Math.abs(melToHertz(hertzToMel(440)) - 440) < 1e-9);
136
+ });
137
+
138
+ it("shapes the mel filterbank to the requested band count", () => {
139
+ const frame = fftMagnitudes(sineFrame(8));
140
+ assert.equal(melbands(frame, SAMPLE_RATE).length, 40);
141
+ assert.equal(melbands(frame, SAMPLE_RATE, 24).length, 24);
142
+ assert.equal(melbands(frame, SAMPLE_RATE, 1).length, 1);
143
+ });
144
+
145
+ it("reads a flat spectrum as equal mel bands", () => {
146
+ const bands = melbands(new Float32Array(SIZE / 2 + 1).fill(1), SAMPLE_RATE, 16);
147
+ for (let b = 0; b < bands.length; b++) {
148
+ assert.ok(Math.abs(bands[b] - 1) < 1e-6, `flat band ${b} reads ${bands[b]}, expected 1`);
149
+ }
150
+ });
151
+
152
+ it("keeps single-bin energy local to its mel triangles", () => {
153
+ const magnitudes = new Float32Array(SIZE / 2 + 1);
154
+ magnitudes[100] = 1; // 100 · 7.8125 Hz = 781.25 Hz
155
+ const bands = melbands(magnitudes, SAMPLE_RATE, 40);
156
+ let nonzero = 0;
157
+ let best = 0;
158
+ for (let b = 0; b < bands.length; b++) {
159
+ if (bands[b] > 0) nonzero++;
160
+ if (bands[b] > bands[best]) best = b;
161
+ }
162
+ assert.ok(nonzero >= 1 && nonzero <= 2, `${nonzero} bands lit by a single bin`);
163
+ const center = melToHertz((hertzToMel(SAMPLE_RATE / 2) * (best + 1)) / 41);
164
+ assert.ok(Math.abs(center - 781.25) < 100, `peak band center ${center} Hz, tone at 781.25 Hz`);
165
+ });
166
+
167
+ it("ranks centroids of low and high sines", () => {
168
+ const low = spectralCentroid(fftMagnitudes(sineFrame(8)), SAMPLE_RATE);
169
+ const high = spectralCentroid(fftMagnitudes(sineFrame(64)), SAMPLE_RATE);
170
+ assert.ok(low < high, `low centroid ${low} should sit under high ${high}`);
171
+ assert.ok(Math.abs(low - 8 * STEP) < STEP, `low centroid ${low}, expected near ${8 * STEP}`);
172
+ assert.ok(Math.abs(high - 64 * STEP) < STEP, `high centroid ${high}, expected near ${64 * STEP}`);
173
+ });
174
+
175
+ it("scores noise flatter than a sine", () => {
176
+ const tone = spectralFlatness(fftMagnitudes(sineFrame(8)));
177
+ const noise = spectralFlatness(fftMagnitudes(noiseFrame(SIZE, 3)));
178
+ assert.ok(tone >= 0 && tone <= 1 && noise >= 0 && noise <= 1);
179
+ assert.ok(noise > tone, `noise flatness ${noise} should beat tone ${tone}`);
180
+ });
181
+
182
+ it("reports zero flux for silence and steady frames", () => {
183
+ const silence = new Float32Array(513);
184
+ assert.equal(spectralFlux(silence, silence), 0);
185
+ const frame = fftMagnitudes(sineFrame(8));
186
+ assert.equal(spectralFlux(frame, frame), 0);
187
+ });
188
+
189
+ it("peaks flux at the note onsets of a two-tone fixture", () => {
190
+ const size = 256;
191
+ const toneA = sineFrame(4, size); // 125 Hz — bin 4
192
+ const toneB = sineFrame(24, size); // 750 Hz — bin 24
193
+ const both = new Float32Array(size);
194
+ for (let i = 0; i < size; i++) both[i] = toneA[i] + toneB[i];
195
+ const signal = new Float32Array(size * 5); // two silent frames, then the notes
196
+ for (let i = 0; i < size; i++) {
197
+ signal[size * 2 + i] = toneA[i];
198
+ signal[size * 3 + i] = toneA[i];
199
+ signal[size * 4 + i] = both[i];
200
+ }
201
+ const frames = stft(signal, { size, hop: size, window: "hann" });
202
+ assert.equal(frames.length, 5);
203
+ const fluxes = frames.map((frame, f) => (f === 0 ? 0 : spectralFlux(frame, frames[f - 1])));
204
+ assert.equal(fluxes[1], 0, "silence → silence is flat");
205
+ assert.equal(fluxes[3], 0, "steady tone A is flat");
206
+ assert.ok(fluxes[2] > 0 && fluxes[4] > 0, "both note onsets light up");
207
+ const peak = fluxes.indexOf(Math.max(...fluxes));
208
+ assert.ok(peak === 2 || peak === 4, `flux peak at frame ${peak}, expected an onset frame`);
209
+ });
210
+
211
+ it("normalizes bandBalance to sum 1", () => {
212
+ const frames = [fftMagnitudes(sineFrame(8)), fftMagnitudes(noiseFrame(SIZE, 11))];
213
+ assert.equal(bandEnergies(frames[0], SAMPLE_RATE).length, BAND_NAMES.length);
214
+ assert.equal(BAND_EDGES_HZ.length, BAND_NAMES.length + 1);
215
+ const summary = summarizeSpectrum(frames, SAMPLE_RATE);
216
+ assert.equal(summary.bandBalance.length, BAND_NAMES.length);
217
+ const balance = total(summary.bandBalance);
218
+ assert.ok(Math.abs(balance - 1) < 1e-9, `bandBalance sums to ${balance}`);
219
+ for (const share of summary.bandBalance) assert.ok(share >= 0 && share <= 1);
220
+ assert.ok(summary.brightnessIndex >= 0 && summary.brightnessIndex <= 1);
221
+ assert.ok(summary.centroidMean > 0 && summary.rolloffMean > 0);
222
+ });
223
+
224
+ it("decimates a spectrogram to width×height grayscale", () => {
225
+ const signal = new Float32Array(4096);
226
+ for (let i = 0; i < signal.length; i++) {
227
+ signal[i] = i < 2048 ? Math.sin((2 * Math.PI * 8 * i) / 512) : Math.sin((2 * Math.PI * 40 * i) / 512);
228
+ }
229
+ const frames = stft(signal, { size: 512, hop: 256 });
230
+ const image = spectrogramImage(frames, 12, 9);
231
+ assert.equal(image.length, 12 * 9);
232
+ let min = 255;
233
+ let max = 0;
234
+ for (const value of image) {
235
+ assert.ok(Number.isInteger(value) && value >= 0 && value <= 255, `gray value ${value} out of range`);
236
+ if (value < min) min = value;
237
+ if (value > max) max = value;
238
+ }
239
+ assert.equal(min, 0, "the quietest cell maps to 0");
240
+ assert.equal(max, 255, "the loudest cell maps to 255");
241
+ assert.deepEqual(image, spectrogramImage(frames, 12, 9));
242
+ });
243
+
244
+ it("maps digital silence to a black spectrogram", () => {
245
+ const image = spectrogramImage([new Float32Array(257), new Float32Array(257)], 4, 4);
246
+ assert.equal(image.length, 16);
247
+ for (const value of image) assert.equal(value, 0);
248
+ });
249
+
250
+ it("rises rolloff with tone frequency", () => {
251
+ const low = spectralRolloff(fftMagnitudes(sineFrame(8)), SAMPLE_RATE);
252
+ const high = spectralRolloff(fftMagnitudes(sineFrame(64)), SAMPLE_RATE);
253
+ assert.ok(low < high, `rolloff ${low} should sit under ${high}`);
254
+ assert.ok(Math.abs(low - 8 * STEP) < STEP, `low rolloff ${low}, expected near ${8 * STEP}`);
255
+ assert.ok(Math.abs(high - 64 * STEP) < STEP, `high rolloff ${high}, expected near ${64 * STEP}`);
256
+ });
257
+ });
@@ -0,0 +1,292 @@
1
+ // # audiostructure.test — honest unit tests for the structure layer, runnable
2
+ // with vitest:
3
+ // pnpm vitest run tests/audiostructure.test.ts
4
+ import assert from "node:assert/strict";
5
+ import { describe, it } from "vitest";
6
+ import {
7
+ analyzeStructure,
8
+ energyCurve,
9
+ frameEnergies,
10
+ labelSections,
11
+ noveltyCurve,
12
+ type Section,
13
+ type StructureStats,
14
+ structureBoundaries,
15
+ tensionCurve,
16
+ } from "../audiostructure.ts";
17
+
18
+ const RATE = 100; // 100 Hz analysis frames → 10 ms per frame
19
+ const INTRO = [1, 0.2, 0.1, 0.05] as const;
20
+ const BUILD = [0.2, 1, 0.5, 0.3] as const;
21
+ const PEAK = [0.8, 0.1, 0.4, 1] as const;
22
+ const OUTRO = [0.05, 0.3, 0.15, 0.1] as const;
23
+
24
+ function scaled(dir: readonly number[], k: number): Float32Array {
25
+ return Float32Array.from(dir, (v) => v * k);
26
+ }
27
+
28
+ /** The synthetic song: quiet intro (9 s) → rising build (12 s) → loud peak (12 s) → quiet outro (9 s). */
29
+ function songFrames(): Float32Array[] {
30
+ const frames: Float32Array[] = [];
31
+ for (let i = 0; i < 900; i += 1) frames.push(scaled(INTRO, 0.12));
32
+ for (let i = 0; i < 1200; i += 1) frames.push(scaled(BUILD, 0.4 + (0.6 * i) / 1199));
33
+ for (let i = 0; i < 1200; i += 1) frames.push(scaled(PEAK, 1.6));
34
+ for (let i = 0; i < 900; i += 1) frames.push(scaled(OUTRO, 0.1));
35
+ return frames;
36
+ }
37
+
38
+ /** The arch: quiet — loud — quiet, symmetric thirds. */
39
+ function archFrames(): Float32Array[] {
40
+ const frames: Float32Array[] = [];
41
+ for (let i = 0; i < 1000; i += 1) frames.push(scaled(INTRO, 0.12));
42
+ for (let i = 0; i < 1000; i += 1) frames.push(scaled(PEAK, 1.6));
43
+ for (let i = 0; i < 1000; i += 1) frames.push(scaled(INTRO, 0.12));
44
+ return frames;
45
+ }
46
+
47
+ /** The flat line: one constant texture, no joints at all. */
48
+ function flatFrames(): Float32Array[] {
49
+ const frames: Float32Array[] = [];
50
+ for (let i = 0; i < 1200; i += 1) frames.push(scaled([0.5, 0.4, 0.3, 0.2], 0.5));
51
+ return frames;
52
+ }
53
+
54
+ /** The wave: loud — quiet — loud — quiet, 8 s blocks. */
55
+ function waveFrames(): Float32Array[] {
56
+ const frames: Float32Array[] = [];
57
+ for (let block = 0; block < 4; block += 1) {
58
+ const loud = block % 2 === 0;
59
+ for (let i = 0; i < 800; i += 1) frames.push(scaled(loud ? PEAK : INTRO, loud ? 1.6 : 0.12));
60
+ }
61
+ return frames;
62
+ }
63
+
64
+ /** Three identical 8 s blocks (A/B halves inside each block) — perfectly periodic. */
65
+ function repeatingFrames(): Float32Array[] {
66
+ const frames: Float32Array[] = [];
67
+ for (let block = 0; block < 3; block += 1) {
68
+ for (let j = 0; j < 800; j += 1) {
69
+ const dir = j < 400 ? [1, 0.1, 0.3, 0.2] : [0.1, 0.9, 0.2, 0.6];
70
+ frames.push(scaled(dir, 0.7 + (0.2 * (j % 400)) / 399));
71
+ }
72
+ }
73
+ return frames;
74
+ }
75
+
76
+ /** The same internal motion, but every block shifts its directions — through-composed. */
77
+ function throughFrames(): Float32Array[] {
78
+ const base = [
79
+ [1, 0.1, 0.3, 0.2],
80
+ [0.1, 0.9, 0.2, 0.6],
81
+ ] as const;
82
+ const frames: Float32Array[] = [];
83
+ for (let block = 0; block < 3; block += 1) {
84
+ for (let j = 0; j < 800; j += 1) {
85
+ const dir = base[j < 400 ? 0 : 1];
86
+ const vec = scaled(
87
+ [dir[(0 + block) % 4], dir[(1 + block) % 4], dir[(2 + block) % 4], dir[(3 + block) % 4]],
88
+ 0.7 + (0.2 * (j % 400)) / 399,
89
+ );
90
+ frames.push(vec);
91
+ }
92
+ }
93
+ return frames;
94
+ }
95
+
96
+ function jointMax(novelty: Float32Array, ms: number): number {
97
+ const frame = Math.round((ms / 1000) * RATE);
98
+ let top = 0;
99
+ for (let i = Math.max(0, frame - 120); i <= Math.min(novelty.length - 1, frame + 120); i += 1)
100
+ top = Math.max(top, novelty[i]);
101
+ return top;
102
+ }
103
+
104
+ function maxOf(values: Float32Array): number {
105
+ let top = 0;
106
+ for (const v of values) top = Math.max(top, v);
107
+ return top;
108
+ }
109
+
110
+ describe("audiostructure novelty", () => {
111
+ it("spikes at the section joints and stays quiet inside sections", () => {
112
+ const novelty = noveltyCurve(songFrames(), { frameRate: RATE });
113
+ assert.equal(novelty.length, 4200);
114
+ for (const v of novelty) assert.ok(Number.isFinite(v) && v >= 0 && v <= 1);
115
+ assert.ok(jointMax(novelty, 9000) > 0.3, "intro → build joint spikes");
116
+ assert.ok(jointMax(novelty, 21000) > 0.3, "build → peak joint spikes");
117
+ assert.ok(jointMax(novelty, 33000) > 0.3, "peak → outro joint spikes");
118
+ for (let f = 300; f <= 500; f += 1) assert.ok(novelty[f] < 0.02, "intro interior stays quiet");
119
+ for (let f = 1400; f <= 1700; f += 1)
120
+ assert.ok(novelty[f] < 0.02, "the rising build keeps one direction, so it stays quiet");
121
+ });
122
+
123
+ it("answers empty for empty input or a broken frame rate", () => {
124
+ assert.equal(noveltyCurve([], { frameRate: RATE }).length, 0);
125
+ assert.equal(noveltyCurve(songFrames(), { frameRate: 0 }).length, 0);
126
+ assert.equal(noveltyCurve(songFrames(), { frameRate: Number.NaN }).length, 0);
127
+ assert.equal(frameEnergies([]).length, 0);
128
+ });
129
+ });
130
+
131
+ describe("audiostructure boundaries", () => {
132
+ it("finds one boundary per joint and respects minSectionMs", () => {
133
+ const novelty = noveltyCurve(songFrames(), { frameRate: RATE });
134
+ const bounds = structureBoundaries(novelty, RATE);
135
+ assert.equal(bounds.length, 3);
136
+ for (const joint of [9000, 21000, 33000]) {
137
+ assert.ok(
138
+ bounds.some((b) => Math.abs(b - joint) <= 1200),
139
+ `a boundary lands near ${joint}ms`,
140
+ );
141
+ }
142
+ for (let i = 1; i < bounds.length; i += 1)
143
+ assert.ok(bounds[i] - bounds[i - 1] >= 8000, "kept boundaries honor the minimum section");
144
+ });
145
+
146
+ it("a huge minSectionMs keeps only the strongest joint; a high threshold drops all", () => {
147
+ const novelty = noveltyCurve(songFrames(), { frameRate: RATE });
148
+ const starved = structureBoundaries(novelty, RATE, { minSectionMs: 60000 });
149
+ assert.equal(starved.length, 1, "strongest-first greedy keeps the single strongest boundary");
150
+ const apexMs = (novelty.reduce((best, v, i) => (v > novelty[best] ? i : best), 0) / RATE) * 1000;
151
+ assert.equal(
152
+ starved[0],
153
+ structureBoundaries(novelty, RATE).reduce((a, b) => (Math.abs(b - apexMs) < Math.abs(a - apexMs) ? b : a), 0),
154
+ "…which sits on the strongest novelty peak (the curve's global maximum, not its latest boundary)",
155
+ );
156
+ assert.deepEqual(structureBoundaries(novelty, RATE, { threshold: 2 }), []);
157
+ assert.deepEqual(structureBoundaries(new Float32Array(0), RATE), []);
158
+ });
159
+ });
160
+
161
+ describe("audiostructure sections", () => {
162
+ it("tiles the song into intro/build/peak/outro without gaps or overlaps", () => {
163
+ const rms = frameEnergies(songFrames());
164
+ const novelty = noveltyCurve(songFrames(), { frameRate: RATE });
165
+ const sections: Section[] = labelSections(structureBoundaries(novelty, RATE), rms, RATE);
166
+ assert.deepEqual(
167
+ sections.map((s) => s.role),
168
+ ["intro", "build", "peak", "outro"],
169
+ );
170
+ assert.equal(sections[0].startMs, 0);
171
+ assert.equal(sections[sections.length - 1].endMs, 42000);
172
+ for (let i = 1; i < sections.length; i += 1)
173
+ assert.equal(sections[i].startMs, sections[i - 1].endMs, "adjacent sections share their edge");
174
+ const [first, build, peak] = sections;
175
+ assert.ok(peak.energyMean > build.energyMean, "the peak section out-louds the build");
176
+ assert.ok(build.energyMean > first.energyMean, "the build out-louds the intro");
177
+ for (const s of sections) assert.ok(s.energyPeak >= s.energyMean);
178
+ });
179
+
180
+ it("a lone section is its own peak and broken inputs answer empty", () => {
181
+ const rms = frameEnergies(flatFrames());
182
+ assert.deepEqual(
183
+ labelSections([], rms, RATE).map((s) => ({ role: s.role, startMs: s.startMs, endMs: s.endMs })),
184
+ [{ role: "peak", startMs: 0, endMs: 12000 }],
185
+ );
186
+ assert.deepEqual(labelSections([6000], rms, 0), []);
187
+ assert.deepEqual(labelSections([6000], new Float32Array(0), RATE), []);
188
+ assert.deepEqual(
189
+ labelSections([60000, -5, Number.NaN], rms, RATE).map((s) => s.role),
190
+ ["peak"],
191
+ "out-of-range and NaN boundaries clamp or drop into one section",
192
+ );
193
+ });
194
+ });
195
+
196
+ describe("audiostructure curves", () => {
197
+ it("resamples the rms into a 0–1 peak-normalized energy curve", () => {
198
+ const rms = frameEnergies(songFrames());
199
+ const curve = energyCurve(rms);
200
+ assert.equal(curve.length, 64);
201
+ for (const v of curve) assert.ok(Number.isFinite(v) && v >= 0 && v <= 1);
202
+ assert.equal(maxOf(curve), 1, "the loudest sample normalizes to exactly 1");
203
+ assert.ok(curve[63] < 0.1, "the quiet outro tail stays near zero");
204
+ assert.equal(energyCurve(rms, { points: 16 }).length, 16);
205
+ const silent = energyCurve(new Float32Array(0));
206
+ assert.equal(silent.length, 64);
207
+ assert.ok([...silent].every((v) => v === 0));
208
+ });
209
+
210
+ it("blends novelty and energy with the documented weights", () => {
211
+ const rms = frameEnergies(songFrames());
212
+ const novelty = noveltyCurve(songFrames(), { frameRate: RATE });
213
+ const energy = energyCurve(rms);
214
+ const tension = tensionCurve(novelty, energy);
215
+ assert.equal(tension.length, energy.length);
216
+ for (const v of tension) assert.ok(Number.isFinite(v) && v >= 0 && v <= 1);
217
+ assert.ok(maxOf(tension) >= 0.6, "peak loudness alone carries the 0.6 energy weight");
218
+ assert.equal(tensionCurve(novelty, new Float32Array(0)).length, 0);
219
+ });
220
+ });
221
+
222
+ describe("audiostructure analyzeStructure", () => {
223
+ it("reads the synthetic song end to end", () => {
224
+ const stats: StructureStats = analyzeStructure(songFrames(), RATE);
225
+ assert.equal(stats.durationMs, 42000);
226
+ assert.deepEqual(
227
+ stats.sections.map((s) => s.role),
228
+ ["intro", "build", "peak", "outro"],
229
+ );
230
+ assert.equal(stats.narrativeShape, "rise", "the song rises into its peak");
231
+ assert.ok(
232
+ stats.peakSectionMs >= 20000 && stats.peakSectionMs <= 23000,
233
+ "peakSectionMs points at the peak section start",
234
+ );
235
+ assert.ok(stats.repetitionIndex >= 0 && stats.repetitionIndex <= 1);
236
+ });
237
+
238
+ it("classifies arch, flat and wave narratives", () => {
239
+ assert.equal(analyzeStructure(archFrames(), RATE).narrativeShape, "arch");
240
+ assert.equal(analyzeStructure(flatFrames(), RATE).narrativeShape, "flat");
241
+ assert.equal(analyzeStructure(waveFrames(), RATE).narrativeShape, "wave");
242
+ });
243
+
244
+ it("scores a repeating pattern above a through-composed twin", () => {
245
+ const repeating = analyzeStructure(repeatingFrames(), RATE).repetitionIndex;
246
+ const through = analyzeStructure(throughFrames(), RATE).repetitionIndex;
247
+ assert.ok(Number.isFinite(repeating) && Number.isFinite(through));
248
+ assert.ok(repeating >= 0 && repeating <= 1 && through >= 0 && through <= 1);
249
+ assert.ok(repeating - through > 0.3, `repeating ${repeating} must beat through-composed ${through}`);
250
+ });
251
+
252
+ it("is deterministic across runs", () => {
253
+ assert.deepEqual(analyzeStructure(songFrames(), RATE), analyzeStructure(songFrames(), RATE));
254
+ assert.deepEqual(analyzeStructure(archFrames(), RATE), analyzeStructure(archFrames(), RATE));
255
+ assert.deepEqual(noveltyCurve(songFrames(), { frameRate: RATE }), noveltyCurve(songFrames(), { frameRate: RATE }));
256
+ });
257
+
258
+ it("carries no NaN anywhere across every fixture", () => {
259
+ for (const frames of [songFrames(), archFrames(), flatFrames(), waveFrames(), repeatingFrames(), throughFrames()]) {
260
+ const novelty = noveltyCurve(frames, { frameRate: RATE });
261
+ const bounds = structureBoundaries(novelty, RATE);
262
+ const rms = frameEnergies(frames);
263
+ const energy = energyCurve(rms);
264
+ const stats = analyzeStructure(frames, RATE);
265
+ for (const v of novelty) assert.ok(Number.isFinite(v));
266
+ for (const b of bounds) assert.ok(Number.isFinite(b));
267
+ for (const s of stats.sections) {
268
+ for (const v of [s.startMs, s.endMs, s.energyMean, s.energyPeak]) assert.ok(Number.isFinite(v));
269
+ }
270
+ for (const v of energy) assert.ok(Number.isFinite(v));
271
+ for (const v of tensionCurve(novelty, energy)) assert.ok(Number.isFinite(v));
272
+ for (const v of [stats.durationMs, stats.peakSectionMs, stats.repetitionIndex]) assert.ok(Number.isFinite(v));
273
+ }
274
+ });
275
+
276
+ it("degenerate inputs answer total zeroed values", () => {
277
+ assert.deepEqual(analyzeStructure([], RATE), {
278
+ sections: [],
279
+ durationMs: 0,
280
+ peakSectionMs: 0,
281
+ repetitionIndex: 0,
282
+ narrativeShape: "flat",
283
+ });
284
+ const single = analyzeStructure([Float32Array.from([1, 1, 1, 1])], RATE);
285
+ assert.equal(single.durationMs, 10);
286
+ assert.equal(single.sections.length, 1);
287
+ assert.equal(single.sections[0].role, "peak");
288
+ assert.equal(single.narrativeShape, "flat");
289
+ assert.equal(single.repetitionIndex, 0);
290
+ assert.equal(analyzeStructure(songFrames(), 0).durationMs, 0);
291
+ });
292
+ });