@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
package/README.md CHANGED
@@ -8,9 +8,41 @@ Video and image player, editor and studio. Absorbs iukka (player) and create (ed
8
8
 
9
9
  # Cadria Sol
10
10
 
11
- Cadria is the video, image and 3D studio of the DevThink family, running on the versawase engine. This Sol theme is the studio's web surface — a Vite + React SPA with the gallery, player, studio and settings routes rendering projects, assets and render jobs from the same sqlite database that `schema.prisma` and `drizzle.config.ts` describe.
11
+ Cadria is the video, image and 3D studio of the DevThink family, running on the versawase engine. This Sol theme is the studio's web surface — a Vite + React SPA with the intro (the public presentation), and the platform chrome carrying the home, player, studio, editor (the timeline), gallery and settings routes. Projects, assets and render jobs ride the same sqlite database that `schema.prisma` and `drizzle.config.ts` describe.
12
12
 
13
13
  ```bash
14
14
  pnpm install
15
15
  pnpm dev
16
16
  ```
17
+
18
+ ## Tests
19
+
20
+ The suite rides the node built-in runner — zero extra dependencies, exactly as the test headers document:
21
+
22
+ ```bash
23
+ pnpm test # node --test "tests/*.test.ts" — the engine, the algebra, the view models
24
+ pnpm typecheck # tsc --noEmit
25
+ ```
26
+
27
+ ## Build hygiene (read before building locally)
28
+
29
+ The vite emit lands in the app root (`outDir "."` — the deploy contract of
30
+ netlify/vercel is `publish "."`), so a local build rewrites `index.html` into
31
+ its hashed face and drops the hashed bundles beside the source. The
32
+ `.gitignore` keeps every hashed artifact (`index-*.js`, `index-*.css`,
33
+ `favicon-*.svg`, `manifest-*.webmanifest`) out of the tree — after building
34
+ locally, restore the source entry with:
35
+
36
+ ```bash
37
+ pnpm restore:entry
38
+ ```
39
+
40
+ `restore:entry` is guarded: it refuses to run while HEAD's `index.html` is
41
+ still a hashed face (the source-face fix not landed yet) — a premature
42
+ restore would clobber the uncommitted fix, so the script checks both the
43
+ working tree and HEAD and answers with a clear refusal instead of touching
44
+ the file.
45
+
46
+ The deploy hosts (pages workflow, netlify, vercel) rewrite the entry inside
47
+ their own builds, so the committed `index.html` always stays the source face
48
+ (`./App.tsx` + the canonical `favicon.svg` / `manifest.webmanifest`).
package/docs/.gitkeep CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenathlan/cadria",
3
- "version": "2.0.94",
3
+ "version": "2.0.117",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/wenathlan/devthink"
@@ -17,6 +17,24 @@
17
17
  "static-site"
18
18
  ],
19
19
  "license": "GPL-3.0-only",
20
+ "engines": {
21
+ "bare": "^1.18.0",
22
+ "bun": "^1.4.2",
23
+ "chrome": "^16.0.912",
24
+ "cnpm": "^9.4.0",
25
+ "corepack": "^0.35.0",
26
+ "deno": "^2.9.5",
27
+ "ecmascript": "^5.0.0",
28
+ "firefox": ">=0.8.0",
29
+ "iojs": "^1.0.0",
30
+ "node": "^26.8.2",
31
+ "npm": "^12.1.0",
32
+ "pnpm": "^12.6.0",
33
+ "teleport": ">=0.2.0",
34
+ "typescript": "^5.8.0",
35
+ "vscode": "^1.118.0",
36
+ "yarn": "^4.17.1"
37
+ },
20
38
  "files": [
21
39
  "README.md",
22
40
  "docs",
@@ -37,7 +55,11 @@
37
55
  "build": "vite build",
38
56
  "start": "vite preview --host",
39
57
  "preview": "vite preview --host",
40
- "check": "tsc --noEmit"
58
+ "check": "tsc --noEmit",
59
+ "typecheck": "tsc --noEmit",
60
+ "test": "vitest run",
61
+ "test:watch": "vitest",
62
+ "restore:entry": "node -e \"const{readFileSync}=require('node:fs');const{execSync}=require('node:child_process');const face=/script[^>]*App\\\\.tsx/;if(face.test(readFileSync('index.html','utf8'))){console.log('index.html already rides the source face');process.exit(0)}if(!face.test(execSync('git show HEAD:./index.html',{encoding:'utf8'}))){console.error('refusing: HEAD index.html is not the source face yet - land the source-face fix first; nothing was touched');process.exit(1)}execSync('git checkout -- index.html',{stdio:'inherit'});console.log('index.html restored from HEAD (the source face)')\""
41
63
  },
42
64
  "dependencies": {
43
65
  "@hono/node-server": "^2.1.3",
@@ -53,6 +75,7 @@
53
75
  "@capacitor/cli": "^8.5.2",
54
76
  "@capacitor/core": "^8.5.2",
55
77
  "@capacitor/ios": "^8.5.2",
78
+ "@fluentui/react-icons": "^2.0.344",
56
79
  "@hookform/resolvers": "^5.9.1",
57
80
  "@libsql/client": "^0.18.0",
58
81
  "@opencode-ai/sdk": "^1.18.34",
package/tests/.gitkeep CHANGED
File without changes
@@ -0,0 +1,406 @@
1
+ // # audiodecode.test — honest unit tests for the pcm decode layer, runnable
2
+ // with vitest:
3
+ // pnpm vitest run tests/audiodecode.test.ts
4
+ // Every fixture is synthesized below: riff/wave bytes built by hand (sine, stereo,
5
+ // odd chunk orders, float32, truncated tails) — no binary files on disk.
6
+ import assert from "node:assert/strict";
7
+ import { describe, it } from "vitest";
8
+ import {
9
+ AudioDecodeError,
10
+ type AudioFrames,
11
+ decodeWav,
12
+ downmix,
13
+ fromAudioBufferLike,
14
+ normalizePeak,
15
+ resample,
16
+ wavinfo,
17
+ } from "../audiodecode.ts";
18
+
19
+ /** little-endian u16 / u32 byte arrays. */
20
+ const le16 = (value: number): number[] => [value & 0xff, (value >>> 8) & 0xff];
21
+ const le32 = (value: number): number[] => [
22
+ value & 0xff,
23
+ (value >>> 8) & 0xff,
24
+ (value >>> 16) & 0xff,
25
+ (value >>> 24) & 0xff,
26
+ ];
27
+
28
+ /** the ascii bytes of a chunk id. */
29
+ const ascii = (text: string): number[] => [...text].map((char) => char.charCodeAt(0));
30
+
31
+ /** one riff chunk: id, size, body, pad byte when the body is odd-sized. */
32
+ const chunk = (id: string, body: number[]): number[] => [
33
+ ...ascii(id),
34
+ ...le32(body.length),
35
+ ...body,
36
+ ...(body.length & 1 ? [0] : []),
37
+ ];
38
+
39
+ /** a fmt chunk body (tag 1 = pcm, 3 = ieee float, 0xfffe = extensible). */
40
+ const fmt = (channels: number, sampleRate: number, bits: number, tag = 1): number[] => {
41
+ const align = channels * (bits >> 3);
42
+ return [
43
+ ...le16(tag),
44
+ ...le16(channels),
45
+ ...le32(sampleRate),
46
+ ...le32(sampleRate * align),
47
+ ...le16(align),
48
+ ...le16(bits),
49
+ ];
50
+ };
51
+
52
+ /** a full riff/wave file from its chunks. */
53
+ const wav = (...chunks: number[][]): Uint8Array => {
54
+ const body = [...ascii("WAVE"), ...chunks.flat()];
55
+ return Uint8Array.from([...ascii("RIFF"), ...le32(4 + body.length), ...body]);
56
+ };
57
+
58
+ /** signed little-endian integer sample payloads. */
59
+ const pcm16 = (values: number[]): number[] => values.flatMap((value) => le16(value < 0 ? value + 65536 : value));
60
+ const pcm24 = (values: number[]): number[] =>
61
+ values.flatMap((value) => [value & 0xff, (value >> 8) & 0xff, (value >> 16) & 0xff]);
62
+ const pcm32 = (values: number[]): number[] => values.flatMap((value) => le32(value < 0 ? value + 4294967296 : value));
63
+
64
+ /** float32 little-endian samples. */
65
+ const f32 = (values: number[]): number[] => {
66
+ const view = new DataView(new ArrayBuffer(4));
67
+ return values.flatMap((value) => {
68
+ view.setFloat32(0, value, true);
69
+ return [...new Uint8Array(view.buffer)];
70
+ });
71
+ };
72
+
73
+ /** a deterministic sine in sample values. */
74
+ const sine = (frames: number, frequency: number, rate: number, amplitude = 0.8): number[] =>
75
+ Array.from({ length: frames }, (_, index) => amplitude * Math.sin((2 * Math.PI * frequency * index) / rate));
76
+
77
+ /** pcm16 sine rounded into the container — the workhorse mono fixture (0.1 s at 48000). */
78
+ const quantize = (values: number[], span: number): number[] => values.map((value) => Math.round(value * span));
79
+ const sineFile = (): Uint8Array =>
80
+ wav(chunk("fmt ", fmt(1, 48000, 16)), chunk("data", pcm16(quantize(sine(4800, 440, 48000), 32767))));
81
+
82
+ /** stereo interleaver for two sample arrays. */
83
+ const zip = (left: number[], right: number[]): number[] =>
84
+ left.flatMap((value, index) => [...le16(value), ...le16(right[index])]);
85
+
86
+ /** builds AudioFrames by hand for the pure dsp helpers. */
87
+ const framesOf = (sampleRate: number, samples: number[]): AudioFrames => ({
88
+ sampleRate,
89
+ channels: 1,
90
+ length: samples.length,
91
+ samples: Float32Array.from(samples),
92
+ });
93
+
94
+ /** runs a thunk and answers its AudioDecodeError code (or "no-throw"). */
95
+ const codeOf = (run: () => unknown): string => {
96
+ try {
97
+ run();
98
+ } catch (error) {
99
+ return error instanceof AudioDecodeError ? error.code : "";
100
+ }
101
+ return "no-throw";
102
+ };
103
+
104
+ describe("audiodecode wav walk", () => {
105
+ it("answers the header without decoding samples", () => {
106
+ const info = wavinfo(wav(chunk("fmt ", fmt(2, 44100, 16)), chunk("data", pcm16([0, 0, 0, 0]))));
107
+ assert.deepEqual(
108
+ [info.sampleRate, info.channels, info.bitDepth, info.isFloat, info.frames],
109
+ [44100, 2, 16, false, 2],
110
+ );
111
+ assert.equal(info.durationSeconds, 2 / 44100);
112
+ });
113
+
114
+ it("decodes 8-bit pcm centered at 128", () => {
115
+ const bytes = wav(chunk("fmt ", fmt(1, 8000, 8)), chunk("data", [128, 255, 0, 64]));
116
+ assert.deepEqual([...decodeWav(bytes, { targetSampleRate: 8000 }).samples], [0, 127 / 128, -1, -0.5]);
117
+ });
118
+
119
+ it("decodes 16-bit pcm including negative full scale", () => {
120
+ const bytes = wav(chunk("fmt ", fmt(1, 8000, 16)), chunk("data", pcm16([0, 16384, -32768, 32767])));
121
+ assert.deepEqual([...decodeWav(bytes, { targetSampleRate: 8000 }).samples], [0, 0.5, -1, 32767 / 32768]);
122
+ });
123
+
124
+ it("decodes 24-bit pcm with sign extension", () => {
125
+ const bytes = wav(chunk("fmt ", fmt(1, 8000, 24)), chunk("data", pcm24([0, 8388607, -8388608, 4194304])));
126
+ assert.deepEqual([...decodeWav(bytes, { targetSampleRate: 8000 }).samples], [0, 8388607 / 8388608, -1, 0.5]);
127
+ });
128
+
129
+ it("decodes 32-bit pcm", () => {
130
+ const samples = [
131
+ ...decodeWav(
132
+ wav(chunk("fmt ", fmt(1, 8000, 32)), chunk("data", pcm32([0, 2147483647, -2147483648, 1073741824]))),
133
+ { targetSampleRate: 8000 },
134
+ ).samples,
135
+ ];
136
+ assert.ok(Math.abs(samples[1] - 2147483647 / 2147483648) < 1e-6); // float32 storage rounds onto full scale
137
+ assert.deepEqual([samples[0], samples[2], samples[3]], [0, -1, 0.5]);
138
+ });
139
+
140
+ it("decodes ieee float32 and keeps over-range values", () => {
141
+ const bytes = wav(chunk("fmt ", fmt(1, 8000, 32, 3)), chunk("data", f32([0, 0.25, -0.5, 2])));
142
+ assert.deepEqual([...decodeWav(bytes, { targetSampleRate: 8000 }).samples], [0, 0.25, -0.5, 2]);
143
+ assert.equal(wavinfo(bytes).isFloat, true);
144
+ });
145
+
146
+ it("reads wave-format-extensible by its subformat guid", () => {
147
+ const body = [
148
+ ...fmt(1, 48000, 24),
149
+ ...le16(22),
150
+ ...le16(24),
151
+ ...le32(3),
152
+ ...[1, 0, 0, 0, 0, 0, 16, 0, 128, 0, 0, 170, 0, 56, 155, 113],
153
+ ];
154
+ const bytes = wav(chunk("fmt ", body), chunk("data", pcm24([0, 8388607, -8388608])));
155
+ const frames = decodeWav(bytes, { targetSampleRate: 48000 });
156
+ assert.equal(frames.length, 3);
157
+ assert.equal(frames.samples[2], -1);
158
+ });
159
+
160
+ it("walks chunks in any order and eats pad bytes", () => {
161
+ const data = chunk("data", pcm16(quantize(sine(48, 440, 48000), 32767)));
162
+ const bytes = wav(chunk("JUNK", [1, 2, 3]), data, chunk("LIST", [1, 2, 3, 4, 5]), chunk("fmt ", fmt(1, 48000, 16)));
163
+ const frames = decodeWav(bytes, { targetSampleRate: 48000 });
164
+ assert.deepEqual([frames.length, frames.sampleRate], [48, 48000]);
165
+ });
166
+
167
+ it("keeps interleaved channel order when mono is off", () => {
168
+ const bytes = wav(chunk("fmt ", fmt(2, 48000, 16)), chunk("data", pcm16([8192, -8192, 4096, -4096])));
169
+ const frames = decodeWav(bytes, { targetSampleRate: 48000, mono: false });
170
+ assert.deepEqual([frames.channels, frames.length], [2, 2]);
171
+ assert.deepEqual([...frames.samples], [0.25, -0.25, 0.125, -0.125]);
172
+ });
173
+
174
+ it("decodes deterministically — same bytes, same frames", () => {
175
+ const bytes = sineFile();
176
+ assert.deepEqual([...decodeWav(bytes).samples], [...decodeWav(bytes).samples]);
177
+ assert.equal(decodeWav(bytes).length, 4800);
178
+ });
179
+ });
180
+
181
+ describe("audiodecode downmix", () => {
182
+ it("averages two different sines into one mono channel", () => {
183
+ const bytes = wav(
184
+ chunk("fmt ", fmt(2, 48000, 16)),
185
+ chunk("data", zip(quantize(sine(64, 440, 48000), 16384), quantize(sine(64, 880, 48000), 8192))),
186
+ );
187
+ const stereo = decodeWav(bytes, { mono: false });
188
+ const mono = decodeWav(bytes, { mono: true });
189
+ assert.equal(mono.channels, 1);
190
+ assert.equal(mono.length, 64);
191
+ const average = (index: number): number => (stereo.samples[2 * index] + stereo.samples[2 * index + 1]) / 2;
192
+ assert.deepEqual(
193
+ [...mono.samples],
194
+ Array.from({ length: 64 }, (_, index) => average(index)),
195
+ );
196
+ });
197
+
198
+ it("averaging identical channels keeps the sine bit-exact", () => {
199
+ const wave = quantize(sine(48, 440, 48000), 16384);
200
+ const stereo = wav(chunk("fmt ", fmt(2, 48000, 16)), chunk("data", zip(wave, wave)));
201
+ const mono = wav(chunk("fmt ", fmt(1, 48000, 16)), chunk("data", pcm16(wave)));
202
+ assert.deepEqual([...decodeWav(stereo).samples], [...decodeWav(mono).samples]);
203
+ });
204
+
205
+ it("clips only when a float source already passes full scale", () => {
206
+ assert.deepEqual(
207
+ [...downmix({ sampleRate: 8000, channels: 2, length: 1, samples: Float32Array.from([2, -3]) }).samples],
208
+ [-0.5],
209
+ );
210
+ assert.deepEqual(
211
+ [...downmix({ sampleRate: 8000, channels: 2, length: 1, samples: Float32Array.from([2, 3]) }).samples],
212
+ [1],
213
+ );
214
+ const mono = framesOf(8000, [0.5]);
215
+ assert.equal(downmix(mono), mono);
216
+ });
217
+ });
218
+
219
+ describe("audiodecode resample", () => {
220
+ it("keeps the same object when the rates match", () => {
221
+ const source = framesOf(48000, [0, 0.5, -0.5, 1]);
222
+ assert.equal(resample(source, 48000), source);
223
+ const decoded = decodeWav(sineFile());
224
+ assert.equal(decoded.sampleRate, 48000);
225
+ assert.equal(decoded.length, 4800);
226
+ });
227
+
228
+ it("downsamples by an exact ratio picking every other sample", () => {
229
+ const half = resample(framesOf(48000, [0, 0.5, 1, 0.5, 0, -0.5]), 24000);
230
+ assert.equal(half.sampleRate, 24000);
231
+ assert.equal(half.length, 3);
232
+ assert.deepEqual([...half.samples], [0, 1, 0]);
233
+ });
234
+
235
+ it("interpolates halfway points when upsampling and holds the tail", () => {
236
+ const up = resample(framesOf(1000, [0, 1]), 2000);
237
+ assert.equal(up.length, 4);
238
+ assert.deepEqual([...up.samples], [0, 0.5, 1, 1]);
239
+ });
240
+
241
+ it("resamples inside decodeWav with ratio length math", () => {
242
+ const frames = decodeWav(sineFile(), { targetSampleRate: 22050 });
243
+ assert.equal(frames.sampleRate, 22050);
244
+ assert.equal(frames.length, 2205); // floor(4800 * 22050 / 48000)
245
+ });
246
+
247
+ it("resamples stereo per channel without bleeding", () => {
248
+ const source: AudioFrames = {
249
+ sampleRate: 48000,
250
+ channels: 2,
251
+ length: 4,
252
+ samples: Float32Array.from([0, 1, 0.5, 0.25, 1, 0, 0.25, 0.5]),
253
+ };
254
+ const half = resample(source, 24000);
255
+ assert.equal(half.channels, 2);
256
+ assert.deepEqual([...half.samples], [0, 1, 1, 0]);
257
+ });
258
+
259
+ it("clamps duration at the source rate before resampling", () => {
260
+ const bytes = wav(chunk("fmt ", fmt(1, 24000, 16)), chunk("data", pcm16(quantize(sine(2400, 440, 24000), 32767))));
261
+ const head = decodeWav(bytes, { maxDurationSeconds: 0.03125 }); // 2^-5 s → 750 source frames → 1500 at 48000
262
+ assert.equal(head.length, 1500);
263
+ const full = decodeWav(bytes);
264
+ // the body matches sample for sample; only the clamped tail differs, because
265
+ // the head holds its last source sample where the full file interpolates on
266
+ assert.deepEqual([...head.samples.slice(0, 1499)], [...full.samples.slice(0, 1499)]);
267
+ assert.equal(head.samples[1499], full.samples[1498]);
268
+ assert.equal(decodeWav(bytes, { maxDurationSeconds: 0.00001 }).length, 0); // below one frame
269
+ });
270
+ });
271
+
272
+ describe("audiodecode normalize", () => {
273
+ it("lifts the loudest sample to the ceiling and bounds the rest", () => {
274
+ const normalized = normalizePeak(framesOf(8000, [0.1, -0.2, 0.25, 0]), 0.98);
275
+ assert.ok(Math.abs(normalized.samples[2] - 0.98) < 1e-6 && Math.abs(normalized.samples[1] + 0.784) < 1e-6); // float32 lands
276
+ assert.ok([...normalized.samples].every((sample) => Math.abs(sample) <= 0.98 + 1e-6));
277
+ });
278
+
279
+ it("attenuates hot input and leaves silence or matched peaks untouched", () => {
280
+ const hot = normalizePeak(framesOf(8000, [1, -1, 0]));
281
+ assert.ok(Math.abs(hot.samples[0] - 0.98) < 1e-6 && Math.abs(hot.samples[1] + 0.98) < 1e-6 && hot.samples[2] === 0);
282
+ const silence = framesOf(8000, [0, 0, 0]);
283
+ assert.equal(normalizePeak(silence), silence);
284
+ const full = framesOf(8000, [1, -0.5, 0]);
285
+ assert.equal(normalizePeak(full, 1), full); // peak already on the ceiling → same object
286
+ });
287
+ });
288
+
289
+ describe("audiodecode errors", () => {
290
+ it("rejects non-riff bytes, non-wave riff containers and undersized files", () => {
291
+ assert.equal(
292
+ codeOf(() => decodeWav(Uint8Array.from([...ascii("OggS"), ...new Array<number>(20).fill(0)]))),
293
+ "wav-not-riff",
294
+ );
295
+ assert.equal(
296
+ codeOf(() => decodeWav(Uint8Array.from([...ascii("RIFF"), ...le32(4), ...ascii("ACON")]))),
297
+ "wav-not-wave",
298
+ );
299
+ assert.equal(
300
+ codeOf(() => decodeWav(new Uint8Array(11))),
301
+ "wav-too-small",
302
+ );
303
+ });
304
+
305
+ it("rejects truncation at the payload and at a chunk header", () => {
306
+ const bytes = sineFile();
307
+ assert.equal(
308
+ codeOf(() => decodeWav(bytes.slice(0, bytes.byteLength - 40))),
309
+ "wav-truncated",
310
+ );
311
+ const header = Uint8Array.from([...ascii("RIFF"), ...le32(100), ...ascii("WAVE"), ...ascii("fmt "), ...le32(999)]);
312
+ assert.equal(
313
+ codeOf(() => decodeWav(header)),
314
+ "wav-truncated",
315
+ );
316
+ });
317
+
318
+ it("rejects empty data and missing chunks", () => {
319
+ assert.equal(
320
+ codeOf(() => decodeWav(wav(chunk("fmt ", fmt(1, 48000, 16)), chunk("data", [])))),
321
+ "wav-empty-data",
322
+ );
323
+ assert.equal(
324
+ codeOf(() => decodeWav(wav(chunk("data", pcm16([0, 0]))))),
325
+ "wav-missing-fmt",
326
+ );
327
+ assert.equal(
328
+ codeOf(() => decodeWav(wav(chunk("fmt ", fmt(1, 48000, 16))))),
329
+ "wav-missing-data",
330
+ );
331
+ });
332
+
333
+ it("rejects unsupported format tags and bit depths", () => {
334
+ assert.equal(
335
+ codeOf(() => decodeWav(wav(chunk("fmt ", fmt(1, 48000, 16, 6)), chunk("data", [0, 0])))),
336
+ "wav-unsupported-format",
337
+ );
338
+ assert.equal(
339
+ codeOf(() => decodeWav(wav(chunk("fmt ", fmt(1, 48000, 12)), chunk("data", [0, 0])))),
340
+ "wav-unsupported-bit-depth",
341
+ );
342
+ assert.equal(
343
+ codeOf(() => decodeWav(wav(chunk("fmt ", fmt(1, 48000, 64, 3)), chunk("data", f32([0, 1]))))),
344
+ "wav-unsupported-bit-depth",
345
+ );
346
+ });
347
+
348
+ it("rejects decode options that make no sense", () => {
349
+ assert.equal(
350
+ codeOf(() => decodeWav(sineFile(), { targetSampleRate: 0 })),
351
+ "decode-bad-target-rate",
352
+ );
353
+ assert.equal(
354
+ codeOf(() => decodeWav(sineFile(), { maxDurationSeconds: -1 })),
355
+ "decode-bad-max-duration",
356
+ );
357
+ assert.equal(
358
+ codeOf(() => resample(framesOf(8000, [1]), Number.NaN)),
359
+ "decode-bad-target-rate",
360
+ );
361
+ assert.equal(
362
+ codeOf(() => normalizePeak(framesOf(8000, [1]), 0)),
363
+ "decode-bad-ceiling",
364
+ );
365
+ });
366
+ });
367
+
368
+ describe("audiodecode browser bridge", () => {
369
+ it("maps channel arrays onto the interleaved contract with a copy", () => {
370
+ const left = Float32Array.from([0.5, 0.25]);
371
+ const right = Float32Array.from([-0.5, -0.25]);
372
+ const frames = fromAudioBufferLike({ sampleRate: 44100, channelData: [left, right] });
373
+ assert.equal(frames.sampleRate, 44100);
374
+ assert.equal(frames.channels, 2);
375
+ assert.equal(frames.length, 2);
376
+ assert.deepEqual([...frames.samples], [0.5, -0.5, 0.25, -0.25]);
377
+ left[0] = 9; // the browser hands out live views — the contract copied its bytes
378
+ assert.equal(frames.samples[0], 0.5);
379
+ });
380
+
381
+ it("runs the whole downstream chain on a browser buffer", () => {
382
+ const frames = fromAudioBufferLike({ sampleRate: 48000, channelData: [Float32Array.from(sine(4800, 440, 48000))] });
383
+ const chain = normalizePeak(resample(downmix(frames), 24000));
384
+ assert.equal(chain.sampleRate, 24000);
385
+ assert.equal(chain.channels, 1);
386
+ assert.equal(chain.length, 2400);
387
+ assert.ok(Math.abs(Math.max(...[...chain.samples].map(Math.abs)) - 0.98) < 1e-6);
388
+ });
389
+
390
+ it("rejects browser buffers that make no audio", () => {
391
+ assert.equal(
392
+ codeOf(() => fromAudioBufferLike({ sampleRate: 0, channelData: [Float32Array.of(0)] })),
393
+ "decode-bad-sample-rate",
394
+ );
395
+ assert.equal(
396
+ codeOf(() => fromAudioBufferLike({ sampleRate: 48000, channelData: [] })),
397
+ "decode-no-channels",
398
+ );
399
+ assert.equal(
400
+ codeOf(() =>
401
+ fromAudioBufferLike({ sampleRate: 48000, channelData: [Float32Array.of(0), Float32Array.of(1, 2)] }),
402
+ ),
403
+ "decode-uneven-channels",
404
+ );
405
+ });
406
+ });