@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.
- 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 +46 -0
- 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 -6
- 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
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
|
|
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.
|
|
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
|
+
});
|