String of beads: 3D bead-spring solver, control panel, waveform, spectrogram, wav, displacement viewer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zsyegMfQnzJEzgMiFLzew
25 changed files+3869−0
.gitignoreadded+2−0View file
@@ -0,0 +1,2 @@
1+node_modules
2+dist
README.mdadded+28−0View file
@@ -0,0 +1,28 @@
1+# toy-instruments
2+
3+A browser gallery of toy physical-model instruments. Pick an instrument, set its geometry, properties and excitation, run the simulation, and get the pickup waveform, a spectrogram, a playable/downloadable `.wav`, and the bead displacements over time. Static site (Vite + TypeScript); the solver runs in a Web Worker.
4+
5+```
6+npm install
7+npm run dev # http://localhost:5173
8+npm test # vitest
9+npm run build # static site in dist/
10+```
11+
12+## String of beads (first instrument)
13+
14+- `n` point masses on springs with stiffness `k`, spacing `L` at equilibrium, pre-tension `T` (rest length `L0 = L - T/k`). Beads move in 3D and the springs are real (exact length), so geometric nonlinearity is kept: pitch glide at large amplitude, transverse-longitudinal coupling, whirling.
15+- Ends: clamped (fixed point), sliding (massless ring on a rod: x fixed, free across, holds tension), or free (no support; only consistent with `T = 0`). Any bead can be pinned.
16+- Excitation: pluck (triangle between the nearest supports, released at rest), strike (Gaussian initial velocity), hammer (raised-cosine force pulse). Direction is an angle in the y-z plane.
17+- Damping from two decay times: `sigma(omega) = sigma0 + sigma1 omega^2`, fitted to T60 at two frequencies for transverse modes. `sigma0` is mass-proportional; `sigma1` is a dashpot on each spring's relative velocity.
18+- Audio: pickup acceleration (forces / mass, at the full-step velocity), from one bead or summed over all beads, along y, z or x. The `.wav` is normalized to -1 dBFS.
19+- Solver: velocity Verlet (explicit, symplectic without damping). `dt = cfl * 2 / omega_max` (Gershgorin bound with axial `k` and transverse `T/l`), rounded down to a whole number of steps per audio sample.
20+- Checks shown per run: energy balance (energy + dissipated - work in - initial) relative to peak energy, largest strain. Linear transverse and longitudinal modes (Sturm bisection on the tridiagonal stiffness) are marked on the spectrum.
21+
22+Tests (`test/string.test.ts`): modes vs the discrete-string formula; small mode-1 motion vs `cos(omega_1 t)`; energy balance with and without damping/forcing; decay at the requested T60.
23+
24+## Layout
25+
26+- `src/sim/` generic 3D bead-spring system, solver, worker.
27+- `src/instruments/<id>/` model (parameters to system), presets, page. Register new instruments in `src/instruments/registry.ts`.
28+- `src/ui/` reusable controls form, line plot, spectrogram, chain displacement viewer, FFT, WAV.
index.htmladded+13−0View file
@@ -0,0 +1,13 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M1 8h14' stroke='%232a78d6' stroke-width='1.5'/%3E%3Ccircle cx='5' cy='8' r='2' fill='%232a78d6'/%3E%3Ccircle cx='11' cy='8' r='2' fill='%232a78d6'/%3E%3C/svg%3E" />
7+ <title>Toy instruments</title>
8+ </head>
9+ <body>
10+ <div id="app"></div>
11+ <script type="module" src="/src/main.ts"></script>
12+ </body>
13+</html>
package-lock.jsonadded+1619−0View file
@@ -0,0 +1,1619 @@
1+{
2+ "name": "toy-instruments",
3+ "version": "0.1.0",
4+ "lockfileVersion": 3,
5+ "requires": true,
6+ "packages": {
7+ "": {
8+ "name": "toy-instruments",
9+ "version": "0.1.0",
10+ "devDependencies": {
11+ "typescript": "^5.9.0",
12+ "vite": "^7.1.0",
13+ "vitest": "^3.2.0"
14+ }
15+ },
16+ "node_modules/@esbuild/aix-ppc64": {
17+ "version": "0.28.2",
18+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
19+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
20+ "cpu": [
21+ "ppc64"
22+ ],
23+ "dev": true,
24+ "license": "MIT",
25+ "optional": true,
26+ "os": [
27+ "aix"
28+ ],
29+ "engines": {
30+ "node": ">=18"
31+ }
32+ },
33+ "node_modules/@esbuild/android-arm": {
34+ "version": "0.28.2",
35+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
36+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
37+ "cpu": [
38+ "arm"
39+ ],
40+ "dev": true,
41+ "license": "MIT",
42+ "optional": true,
43+ "os": [
44+ "android"
45+ ],
46+ "engines": {
47+ "node": ">=18"
48+ }
49+ },
50+ "node_modules/@esbuild/android-arm64": {
51+ "version": "0.28.2",
52+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
53+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
54+ "cpu": [
55+ "arm64"
56+ ],
57+ "dev": true,
58+ "license": "MIT",
59+ "optional": true,
60+ "os": [
61+ "android"
62+ ],
63+ "engines": {
64+ "node": ">=18"
65+ }
66+ },
67+ "node_modules/@esbuild/android-x64": {
68+ "version": "0.28.2",
69+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
70+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
71+ "cpu": [
72+ "x64"
73+ ],
74+ "dev": true,
75+ "license": "MIT",
76+ "optional": true,
77+ "os": [
78+ "android"
79+ ],
80+ "engines": {
81+ "node": ">=18"
82+ }
83+ },
84+ "node_modules/@esbuild/darwin-arm64": {
85+ "version": "0.28.2",
86+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
87+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
88+ "cpu": [
89+ "arm64"
90+ ],
91+ "dev": true,
92+ "license": "MIT",
93+ "optional": true,
94+ "os": [
95+ "darwin"
96+ ],
97+ "engines": {
98+ "node": ">=18"
99+ }
100+ },
101+ "node_modules/@esbuild/darwin-x64": {
102+ "version": "0.28.2",
103+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
104+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
105+ "cpu": [
106+ "x64"
107+ ],
108+ "dev": true,
109+ "license": "MIT",
110+ "optional": true,
111+ "os": [
112+ "darwin"
113+ ],
114+ "engines": {
115+ "node": ">=18"
116+ }
117+ },
118+ "node_modules/@esbuild/freebsd-arm64": {
119+ "version": "0.28.2",
120+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
121+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
122+ "cpu": [
123+ "arm64"
124+ ],
125+ "dev": true,
126+ "license": "MIT",
127+ "optional": true,
128+ "os": [
129+ "freebsd"
130+ ],
131+ "engines": {
132+ "node": ">=18"
133+ }
134+ },
135+ "node_modules/@esbuild/freebsd-x64": {
136+ "version": "0.28.2",
137+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
138+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
139+ "cpu": [
140+ "x64"
141+ ],
142+ "dev": true,
143+ "license": "MIT",
144+ "optional": true,
145+ "os": [
146+ "freebsd"
147+ ],
148+ "engines": {
149+ "node": ">=18"
150+ }
151+ },
152+ "node_modules/@esbuild/linux-arm": {
153+ "version": "0.28.2",
154+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
155+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
156+ "cpu": [
157+ "arm"
158+ ],
159+ "dev": true,
160+ "license": "MIT",
161+ "optional": true,
162+ "os": [
163+ "linux"
164+ ],
165+ "engines": {
166+ "node": ">=18"
167+ }
168+ },
169+ "node_modules/@esbuild/linux-arm64": {
170+ "version": "0.28.2",
171+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
172+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
173+ "cpu": [
174+ "arm64"
175+ ],
176+ "dev": true,
177+ "license": "MIT",
178+ "optional": true,
179+ "os": [
180+ "linux"
181+ ],
182+ "engines": {
183+ "node": ">=18"
184+ }
185+ },
186+ "node_modules/@esbuild/linux-ia32": {
187+ "version": "0.28.2",
188+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
189+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
190+ "cpu": [
191+ "ia32"
192+ ],
193+ "dev": true,
194+ "license": "MIT",
195+ "optional": true,
196+ "os": [
197+ "linux"
198+ ],
199+ "engines": {
200+ "node": ">=18"
201+ }
202+ },
203+ "node_modules/@esbuild/linux-loong64": {
204+ "version": "0.28.2",
205+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
206+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
207+ "cpu": [
208+ "loong64"
209+ ],
210+ "dev": true,
211+ "license": "MIT",
212+ "optional": true,
213+ "os": [
214+ "linux"
215+ ],
216+ "engines": {
217+ "node": ">=18"
218+ }
219+ },
220+ "node_modules/@esbuild/linux-mips64el": {
221+ "version": "0.28.2",
222+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
223+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
224+ "cpu": [
225+ "mips64el"
226+ ],
227+ "dev": true,
228+ "license": "MIT",
229+ "optional": true,
230+ "os": [
231+ "linux"
232+ ],
233+ "engines": {
234+ "node": ">=18"
235+ }
236+ },
237+ "node_modules/@esbuild/linux-ppc64": {
238+ "version": "0.28.2",
239+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
240+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
241+ "cpu": [
242+ "ppc64"
243+ ],
244+ "dev": true,
245+ "license": "MIT",
246+ "optional": true,
247+ "os": [
248+ "linux"
249+ ],
250+ "engines": {
251+ "node": ">=18"
252+ }
253+ },
254+ "node_modules/@esbuild/linux-riscv64": {
255+ "version": "0.28.2",
256+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
257+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
258+ "cpu": [
259+ "riscv64"
260+ ],
261+ "dev": true,
262+ "license": "MIT",
263+ "optional": true,
264+ "os": [
265+ "linux"
266+ ],
267+ "engines": {
268+ "node": ">=18"
269+ }
270+ },
271+ "node_modules/@esbuild/linux-s390x": {
272+ "version": "0.28.2",
273+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
274+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
275+ "cpu": [
276+ "s390x"
277+ ],
278+ "dev": true,
279+ "license": "MIT",
280+ "optional": true,
281+ "os": [
282+ "linux"
283+ ],
284+ "engines": {
285+ "node": ">=18"
286+ }
287+ },
288+ "node_modules/@esbuild/linux-x64": {
289+ "version": "0.28.2",
290+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
291+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
292+ "cpu": [
293+ "x64"
294+ ],
295+ "dev": true,
296+ "license": "MIT",
297+ "optional": true,
298+ "os": [
299+ "linux"
300+ ],
301+ "engines": {
302+ "node": ">=18"
303+ }
304+ },
305+ "node_modules/@esbuild/netbsd-arm64": {
306+ "version": "0.28.2",
307+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
308+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
309+ "cpu": [
310+ "arm64"
311+ ],
312+ "dev": true,
313+ "license": "MIT",
314+ "optional": true,
315+ "os": [
316+ "netbsd"
317+ ],
318+ "engines": {
319+ "node": ">=18"
320+ }
321+ },
322+ "node_modules/@esbuild/netbsd-x64": {
323+ "version": "0.28.2",
324+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
325+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
326+ "cpu": [
327+ "x64"
328+ ],
329+ "dev": true,
330+ "license": "MIT",
331+ "optional": true,
332+ "os": [
333+ "netbsd"
334+ ],
335+ "engines": {
336+ "node": ">=18"
337+ }
338+ },
339+ "node_modules/@esbuild/openbsd-arm64": {
340+ "version": "0.28.2",
341+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
342+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
343+ "cpu": [
344+ "arm64"
345+ ],
346+ "dev": true,
347+ "license": "MIT",
348+ "optional": true,
349+ "os": [
350+ "openbsd"
351+ ],
352+ "engines": {
353+ "node": ">=18"
354+ }
355+ },
356+ "node_modules/@esbuild/openbsd-x64": {
357+ "version": "0.28.2",
358+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
359+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
360+ "cpu": [
361+ "x64"
362+ ],
363+ "dev": true,
364+ "license": "MIT",
365+ "optional": true,
366+ "os": [
367+ "openbsd"
368+ ],
369+ "engines": {
370+ "node": ">=18"
371+ }
372+ },
373+ "node_modules/@esbuild/openharmony-arm64": {
374+ "version": "0.28.2",
375+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
376+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
377+ "cpu": [
378+ "arm64"
379+ ],
380+ "dev": true,
381+ "license": "MIT",
382+ "optional": true,
383+ "os": [
384+ "openharmony"
385+ ],
386+ "engines": {
387+ "node": ">=18"
388+ }
389+ },
390+ "node_modules/@esbuild/sunos-x64": {
391+ "version": "0.28.2",
392+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
393+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
394+ "cpu": [
395+ "x64"
396+ ],
397+ "dev": true,
398+ "license": "MIT",
399+ "optional": true,
400+ "os": [
401+ "sunos"
402+ ],
403+ "engines": {
404+ "node": ">=18"
405+ }
406+ },
407+ "node_modules/@esbuild/win32-arm64": {
408+ "version": "0.28.2",
409+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
410+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
411+ "cpu": [
412+ "arm64"
413+ ],
414+ "dev": true,
415+ "license": "MIT",
416+ "optional": true,
417+ "os": [
418+ "win32"
419+ ],
420+ "engines": {
421+ "node": ">=18"
422+ }
423+ },
424+ "node_modules/@esbuild/win32-ia32": {
425+ "version": "0.28.2",
426+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
427+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
428+ "cpu": [
429+ "ia32"
430+ ],
431+ "dev": true,
432+ "license": "MIT",
433+ "optional": true,
434+ "os": [
435+ "win32"
436+ ],
437+ "engines": {
438+ "node": ">=18"
439+ }
440+ },
441+ "node_modules/@esbuild/win32-x64": {
442+ "version": "0.28.2",
443+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
444+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
445+ "cpu": [
446+ "x64"
447+ ],
448+ "dev": true,
449+ "license": "MIT",
450+ "optional": true,
451+ "os": [
452+ "win32"
453+ ],
454+ "engines": {
455+ "node": ">=18"
456+ }
457+ },
458+ "node_modules/@jridgewell/sourcemap-codec": {
459+ "version": "1.6.0",
460+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
461+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
462+ "dev": true,
463+ "license": "MIT"
464+ },
465+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
466+ "version": "1.5.1",
467+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
468+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
469+ "cpu": [
470+ "x64"
471+ ],
472+ "dev": true,
473+ "license": "MIT",
474+ "optional": true,
475+ "os": [
476+ "linux"
477+ ],
478+ "engines": {
479+ "node": "^22.20 || ^24.12 || >=25"
480+ }
481+ },
482+ "node_modules/@rollup/rollup-android-arm-eabi": {
483+ "version": "4.64.1",
484+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.1.tgz",
485+ "integrity": "sha512-QRU7UpoS9hd8Ij5I6fCVBxZ0UpdfAbZ5y7BWG5STFGMoM9jGzUP464yYAa3CzZxvG+jBwq1kI8L07xx0ReAdGA==",
486+ "cpu": [
487+ "arm"
488+ ],
489+ "dev": true,
490+ "license": "MIT",
491+ "optional": true,
492+ "os": [
493+ "android"
494+ ]
495+ },
496+ "node_modules/@rollup/rollup-android-arm64": {
497+ "version": "4.64.1",
498+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.1.tgz",
499+ "integrity": "sha512-ttQF6Rp4MrNy9/2AOPmHmMXbNE8kIlNPYoGC3ksid9tonleMQALfrhTFJiQxobBvXYKG5Wcc8VGcHWTHR0ik8w==",
500+ "cpu": [
501+ "arm64"
502+ ],
503+ "dev": true,
504+ "license": "MIT",
505+ "optional": true,
506+ "os": [
507+ "android"
508+ ]
509+ },
510+ "node_modules/@rollup/rollup-darwin-arm64": {
511+ "version": "4.64.1",
512+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.1.tgz",
513+ "integrity": "sha512-MvzZ+qbBi5jrafURHsASUF1qW/58tOJFt/gFVJo2znQWrKX4CSf/Lt/qGONtoA4yHsNL5QzgdXMuITZxAs62Vg==",
514+ "cpu": [
515+ "arm64"
516+ ],
517+ "dev": true,
518+ "license": "MIT",
519+ "optional": true,
520+ "os": [
521+ "darwin"
522+ ]
523+ },
524+ "node_modules/@rollup/rollup-darwin-x64": {
525+ "version": "4.64.1",
526+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.1.tgz",
527+ "integrity": "sha512-9uj1VBuiPK5ij/+g0mCK17X+V/4SbErvRQMXIQjZAtbl4VcUhv63RhPZsFSWHrCRkTd3FKDtaVTrtT0+8bIhhQ==",
528+ "cpu": [
529+ "x64"
530+ ],
531+ "dev": true,
532+ "license": "MIT",
533+ "optional": true,
534+ "os": [
535+ "darwin"
536+ ]
537+ },
538+ "node_modules/@rollup/rollup-freebsd-arm64": {
539+ "version": "4.64.1",
540+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.1.tgz",
541+ "integrity": "sha512-FCoXm836IqcXWhWPRkqlLx3Hjnv9S7c8tg5MFwsgHB3aFCz1JjUy6b1ssrxEjEbqaHpnvRmNjUUHxsXuU85NYA==",
542+ "cpu": [
543+ "arm64"
544+ ],
545+ "dev": true,
546+ "license": "MIT",
547+ "optional": true,
548+ "os": [
549+ "freebsd"
550+ ]
551+ },
552+ "node_modules/@rollup/rollup-freebsd-x64": {
553+ "version": "4.64.1",
554+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.1.tgz",
555+ "integrity": "sha512-Xa6Oxo86Ilx1hq1vNb69541NOuUyhGEH3Sc0g7eGkfsfRblWh4hAa4Lj2GWwZyfUVYL+Z4xK3TxhwIuZ3fNPLg==",
556+ "cpu": [
557+ "x64"
558+ ],
559+ "dev": true,
560+ "license": "MIT",
561+ "optional": true,
562+ "os": [
563+ "freebsd"
564+ ]
565+ },
566+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
567+ "version": "4.64.1",
568+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.1.tgz",
569+ "integrity": "sha512-Nkw7cVMu1yrczpzr42Mh1BZ5Ai5A4Fsz2Je/frbwHmazpQTJzP+qo7pNjdxHrxVj56ivKTJihNoN90OD1hGxBA==",
570+ "cpu": [
571+ "arm"
572+ ],
573+ "dev": true,
574+ "license": "MIT",
575+ "optional": true,
576+ "os": [
577+ "linux"
578+ ]
579+ },
580+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
581+ "version": "4.64.1",
582+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.1.tgz",
583+ "integrity": "sha512-JONaLM9mZPXbSetxMzYG3496qoJuWvG2ERCTrrVptfw/Fwe4UzrQoEx/mYEeZCRv/Xz0RldOLFeqDfqmH75/Zg==",
584+ "cpu": [
585+ "arm"
586+ ],
587+ "dev": true,
588+ "license": "MIT",
589+ "optional": true,
590+ "os": [
591+ "linux"
592+ ]
593+ },
594+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
595+ "version": "4.64.1",
596+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.1.tgz",
597+ "integrity": "sha512-OfimHma+prmdoobHI3tfYud2enQ2LXYaMu+iMUQH7IjJkykJ6qUmAs1M/scdQaEKRWysiKwTctsDa2LXvaO0xw==",
598+ "cpu": [
599+ "arm64"
600+ ],
601+ "dev": true,
602+ "license": "MIT",
603+ "optional": true,
604+ "os": [
605+ "linux"
606+ ]
607+ },
608+ "node_modules/@rollup/rollup-linux-arm64-musl": {
609+ "version": "4.64.1",
610+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.1.tgz",
611+ "integrity": "sha512-X5fwh2HM8EHbgfFwpf4m2x2pG/AHdQ2aPDPk+JCDwV4DPeM8yxYmKG1vvm123JSc9gBSu8tu9NECA99wHPztsw==",
612+ "cpu": [
613+ "arm64"
614+ ],
615+ "dev": true,
616+ "license": "MIT",
617+ "optional": true,
618+ "os": [
619+ "linux"
620+ ]
621+ },
622+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
623+ "version": "4.64.1",
624+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.1.tgz",
625+ "integrity": "sha512-8dp2TxKdjiX/4f7Da9gF7BR29hygxOQlHxKNa8qxJHoqZ13ytrogFTpMPhtVEFrgFu9MSOabAn006PeoygavjA==",
626+ "cpu": [
627+ "loong64"
628+ ],
629+ "dev": true,
630+ "license": "MIT",
631+ "optional": true,
632+ "os": [
633+ "linux"
634+ ]
635+ },
636+ "node_modules/@rollup/rollup-linux-loong64-musl": {
637+ "version": "4.64.1",
638+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.1.tgz",
639+ "integrity": "sha512-YuYDpRSZnU9qLf6sqyhiBdMRCw5ImECGLNqRjM2hZrJIEwAckameWkRJ3SUnn0oxSewZIib/4iLwyEXj40QqQQ==",
640+ "cpu": [
641+ "loong64"
642+ ],
643+ "dev": true,
644+ "license": "MIT",
645+ "optional": true,
646+ "os": [
647+ "linux"
648+ ]
649+ },
650+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
651+ "version": "4.64.1",
652+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.1.tgz",
653+ "integrity": "sha512-gGj3v7YYkysy7IbfclMI4PDtHaa6YX4nU1D5NR1fyvXAGqM4TDk28ZWvrpOkkK92eoQRryAludWz6jNjsm77Qg==",
654+ "cpu": [
655+ "ppc64"
656+ ],
657+ "dev": true,
658+ "license": "MIT",
659+ "optional": true,
660+ "os": [
661+ "linux"
662+ ]
663+ },
664+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
665+ "version": "4.64.1",
666+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.1.tgz",
667+ "integrity": "sha512-DTZVwvS94FUYpP0CjCfz2iT0XTmBfQGc/arx9IdwUbDImpe9h8dWuMT/oPUvcGiwLKMQoNmuFBx3AVajm+FNjA==",
668+ "cpu": [
669+ "ppc64"
670+ ],
671+ "dev": true,
672+ "license": "MIT",
673+ "optional": true,
674+ "os": [
675+ "linux"
676+ ]
677+ },
678+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
679+ "version": "4.64.1",
680+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.1.tgz",
681+ "integrity": "sha512-NWnyiq2moc8iEHGyTjIFadJTy8VCvCU/rUBfor+3l30JOQKAifpmKdxX4ZxRRPSPE/rLhFpLADdzNjS3Ldctdw==",
682+ "cpu": [
683+ "riscv64"
684+ ],
685+ "dev": true,
686+ "license": "MIT",
687+ "optional": true,
688+ "os": [
689+ "linux"
690+ ]
691+ },
692+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
693+ "version": "4.64.1",
694+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.1.tgz",
695+ "integrity": "sha512-yIz+7L37g7bVOGLaUQmGqiYADH262krL4dtf5zU6QGldPgOairLEgW1FTs5pP0UFkHq5uf2qxDttOFsVAX6kxg==",
696+ "cpu": [
697+ "riscv64"
698+ ],
699+ "dev": true,
700+ "license": "MIT",
701+ "optional": true,
702+ "os": [
703+ "linux"
704+ ]
705+ },
706+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
707+ "version": "4.64.1",
708+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.1.tgz",
709+ "integrity": "sha512-sRK+GWovJZV6ijyV/k1Ft3FL5+4DscF5OYsJolTbhVTTLFxKOPVjS0wxUUHakvL0vRw/cbFxZNeZLfM/EzuQhA==",
710+ "cpu": [
711+ "s390x"
712+ ],
713+ "dev": true,
714+ "license": "MIT",
715+ "optional": true,
716+ "os": [
717+ "linux"
718+ ]
719+ },
720+ "node_modules/@rollup/rollup-linux-x64-gnu": {
721+ "version": "4.64.1",
722+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.1.tgz",
723+ "integrity": "sha512-T2C0NXqqD063yJdm8UGdcoudBATDODdphEXWS7rEoh0jOITfoEBv7LHfRBYVsy+pI7wwxgScoZpkFiQOf40ryQ==",
724+ "cpu": [
725+ "x64"
726+ ],
727+ "dev": true,
728+ "license": "MIT",
729+ "optional": true,
730+ "os": [
731+ "linux"
732+ ]
733+ },
734+ "node_modules/@rollup/rollup-linux-x64-musl": {
735+ "version": "4.64.1",
736+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.1.tgz",
737+ "integrity": "sha512-05wl8uRZMkcobB6YZrzskr2SiM0VOdzlFw3bAxOjKKTt3M6o3O0X/hE/8xk15u8oQY17xUCdTKcNMMg45ZAFog==",
738+ "cpu": [
739+ "x64"
740+ ],
741+ "dev": true,
742+ "license": "MIT",
743+ "optional": true,
744+ "os": [
745+ "linux"
746+ ]
747+ },
748+ "node_modules/@rollup/rollup-openbsd-x64": {
749+ "version": "4.64.1",
750+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.1.tgz",
751+ "integrity": "sha512-NoRZK1VclOgn9x3b9vPJ3sTxwvQ/F+Hf93xurRKSFJKiwNZyQZnLxVpqlCejhDvesMLMF8VNN+Y4jHTFddV/Xg==",
752+ "cpu": [
753+ "x64"
754+ ],
755+ "dev": true,
756+ "license": "MIT",
757+ "optional": true,
758+ "os": [
759+ "openbsd"
760+ ]
761+ },
762+ "node_modules/@rollup/rollup-openharmony-arm64": {
763+ "version": "4.64.1",
764+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.1.tgz",
765+ "integrity": "sha512-SDkX4OkZBePUPf0/XsXVJLwS+O61CmkDKuIyk5CeWddK3vkE8AZjmWK+p1ILRez4wnbS5rTK+U9YmcbRyx2dHw==",
766+ "cpu": [
767+ "arm64"
768+ ],
769+ "dev": true,
770+ "license": "MIT",
771+ "optional": true,
772+ "os": [
773+ "openharmony"
774+ ]
775+ },
776+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
777+ "version": "4.64.1",
778+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.1.tgz",
779+ "integrity": "sha512-JQ7el/Plr3juYFxIVu9YXDPNl57XR2L8WIc87JX9wj0L3goV8fXQPQXb3DaHVXq61X/WCGWsTCQsQHo6/Mz5UQ==",
780+ "cpu": [
781+ "arm64"
782+ ],
783+ "dev": true,
784+ "license": "MIT",
785+ "optional": true,
786+ "os": [
787+ "win32"
788+ ]
789+ },
790+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
791+ "version": "4.64.1",
792+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.1.tgz",
793+ "integrity": "sha512-oZSlQ2H3ZDOz3ce1jEyuOZKuD0tPY/D2cKGMcMl0jhHQauBvkHHKAGR1OeRiMluvu3itzLRU2xCTUZptLayObA==",
794+ "cpu": [
795+ "ia32"
796+ ],
797+ "dev": true,
798+ "license": "MIT",
799+ "optional": true,
800+ "os": [
801+ "win32"
802+ ]
803+ },
804+ "node_modules/@rollup/rollup-win32-x64-gnu": {
805+ "version": "4.64.1",
806+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.1.tgz",
807+ "integrity": "sha512-Do2m3jUeVAvUDpee/yVZYRqhO8Ecx4x5sX3EVGalqLx+OxecLDGx3sIwPEm1Nq+MFK2FV3pgdpwWP4624b85Og==",
808+ "cpu": [
809+ "x64"
810+ ],
811+ "dev": true,
812+ "license": "MIT",
813+ "optional": true,
814+ "os": [
815+ "win32"
816+ ]
817+ },
818+ "node_modules/@rollup/rollup-win32-x64-msvc": {
819+ "version": "4.64.1",
820+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.1.tgz",
821+ "integrity": "sha512-KpxCwRn1Qh3tmBHAqIAF4HtUCVKqZd0w57TsItWblx3QVeJLtXaSgeBFAj1WAvBSpBo+Mh9iZCjj6YW/HaBVMw==",
822+ "cpu": [
823+ "x64"
824+ ],
825+ "dev": true,
826+ "license": "MIT",
827+ "optional": true,
828+ "os": [
829+ "win32"
830+ ]
831+ },
832+ "node_modules/@types/chai": {
833+ "version": "5.2.3",
834+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
835+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
836+ "dev": true,
837+ "license": "MIT",
838+ "dependencies": {
839+ "@types/deep-eql": "*",
840+ "assertion-error": "^2.0.1"
841+ }
842+ },
843+ "node_modules/@types/deep-eql": {
844+ "version": "4.0.2",
845+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
846+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
847+ "dev": true,
848+ "license": "MIT"
849+ },
850+ "node_modules/@types/estree": {
851+ "version": "1.0.9",
852+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
853+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
854+ "dev": true,
855+ "license": "MIT"
856+ },
857+ "node_modules/@vitest/expect": {
858+ "version": "3.2.7",
859+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
860+ "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
861+ "dev": true,
862+ "license": "MIT",
863+ "dependencies": {
864+ "@types/chai": "^5.2.2",
865+ "@vitest/spy": "3.2.7",
866+ "@vitest/utils": "3.2.7",
867+ "chai": "^5.2.0",
868+ "tinyrainbow": "^2.0.0"
869+ },
870+ "funding": {
871+ "url": "https://opencollective.com/vitest"
872+ }
873+ },
874+ "node_modules/@vitest/mocker": {
875+ "version": "3.2.7",
876+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
877+ "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
878+ "dev": true,
879+ "license": "MIT",
880+ "dependencies": {
881+ "@vitest/spy": "3.2.7",
882+ "estree-walker": "^3.0.3",
883+ "magic-string": "^0.30.17"
884+ },
885+ "funding": {
886+ "url": "https://opencollective.com/vitest"
887+ },
888+ "peerDependencies": {
889+ "msw": "^2.4.9",
890+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
891+ },
892+ "peerDependenciesMeta": {
893+ "msw": {
894+ "optional": true
895+ },
896+ "vite": {
897+ "optional": true
898+ }
899+ }
900+ },
901+ "node_modules/@vitest/pretty-format": {
902+ "version": "3.2.7",
903+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
904+ "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
905+ "dev": true,
906+ "license": "MIT",
907+ "dependencies": {
908+ "tinyrainbow": "^2.0.0"
909+ },
910+ "funding": {
911+ "url": "https://opencollective.com/vitest"
912+ }
913+ },
914+ "node_modules/@vitest/runner": {
915+ "version": "3.2.7",
916+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
917+ "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
918+ "dev": true,
919+ "license": "MIT",
920+ "dependencies": {
921+ "@vitest/utils": "3.2.7",
922+ "pathe": "^2.0.3",
923+ "strip-literal": "^3.0.0"
924+ },
925+ "funding": {
926+ "url": "https://opencollective.com/vitest"
927+ }
928+ },
929+ "node_modules/@vitest/snapshot": {
930+ "version": "3.2.7",
931+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
932+ "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
933+ "dev": true,
934+ "license": "MIT",
935+ "dependencies": {
936+ "@vitest/pretty-format": "3.2.7",
937+ "magic-string": "^0.30.17",
938+ "pathe": "^2.0.3"
939+ },
940+ "funding": {
941+ "url": "https://opencollective.com/vitest"
942+ }
943+ },
944+ "node_modules/@vitest/spy": {
945+ "version": "3.2.7",
946+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
947+ "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
948+ "dev": true,
949+ "license": "MIT",
950+ "dependencies": {
951+ "tinyspy": "^4.0.3"
952+ },
953+ "funding": {
954+ "url": "https://opencollective.com/vitest"
955+ }
956+ },
957+ "node_modules/@vitest/utils": {
958+ "version": "3.2.7",
959+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
960+ "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
961+ "dev": true,
962+ "license": "MIT",
963+ "dependencies": {
964+ "@vitest/pretty-format": "3.2.7",
965+ "loupe": "^3.1.4",
966+ "tinyrainbow": "^2.0.0"
967+ },
968+ "funding": {
969+ "url": "https://opencollective.com/vitest"
970+ }
971+ },
972+ "node_modules/assertion-error": {
973+ "version": "2.0.1",
974+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
975+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
976+ "dev": true,
977+ "license": "MIT",
978+ "engines": {
979+ "node": ">=12"
980+ }
981+ },
982+ "node_modules/cac": {
983+ "version": "6.7.14",
984+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
985+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
986+ "dev": true,
987+ "license": "MIT",
988+ "engines": {
989+ "node": ">=8"
990+ }
991+ },
992+ "node_modules/chai": {
993+ "version": "5.3.3",
994+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
995+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
996+ "dev": true,
997+ "license": "MIT",
998+ "dependencies": {
999+ "assertion-error": "^2.0.1",
1000+ "check-error": "^2.1.1",
1001+ "deep-eql": "^5.0.1",
1002+ "loupe": "^3.1.0",
1003+ "pathval": "^2.0.0"
1004+ },
1005+ "engines": {
1006+ "node": ">=18"
1007+ }
1008+ },
1009+ "node_modules/check-error": {
1010+ "version": "2.1.3",
1011+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
1012+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
1013+ "dev": true,
1014+ "license": "MIT",
1015+ "engines": {
1016+ "node": ">= 16"
1017+ }
1018+ },
1019+ "node_modules/debug": {
1020+ "version": "4.4.3",
1021+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1022+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1023+ "dev": true,
1024+ "license": "MIT",
1025+ "dependencies": {
1026+ "ms": "^2.1.3"
1027+ },
1028+ "engines": {
1029+ "node": ">=6.0"
1030+ },
1031+ "peerDependenciesMeta": {
1032+ "supports-color": {
1033+ "optional": true
1034+ }
1035+ }
1036+ },
1037+ "node_modules/deep-eql": {
1038+ "version": "5.0.2",
1039+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
1040+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
1041+ "dev": true,
1042+ "license": "MIT",
1043+ "engines": {
1044+ "node": ">=6"
1045+ }
1046+ },
1047+ "node_modules/es-module-lexer": {
1048+ "version": "1.7.0",
1049+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
1050+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
1051+ "dev": true,
1052+ "license": "MIT"
1053+ },
1054+ "node_modules/esbuild": {
1055+ "version": "0.28.2",
1056+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
1057+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
1058+ "dev": true,
1059+ "hasInstallScript": true,
1060+ "license": "MIT",
1061+ "bin": {
1062+ "esbuild": "bin/esbuild"
1063+ },
1064+ "engines": {
1065+ "node": ">=18"
1066+ },
1067+ "optionalDependencies": {
1068+ "@esbuild/aix-ppc64": "0.28.2",
1069+ "@esbuild/android-arm": "0.28.2",
1070+ "@esbuild/android-arm64": "0.28.2",
1071+ "@esbuild/android-x64": "0.28.2",
1072+ "@esbuild/darwin-arm64": "0.28.2",
1073+ "@esbuild/darwin-x64": "0.28.2",
1074+ "@esbuild/freebsd-arm64": "0.28.2",
1075+ "@esbuild/freebsd-x64": "0.28.2",
1076+ "@esbuild/linux-arm": "0.28.2",
1077+ "@esbuild/linux-arm64": "0.28.2",
1078+ "@esbuild/linux-ia32": "0.28.2",
1079+ "@esbuild/linux-loong64": "0.28.2",
1080+ "@esbuild/linux-mips64el": "0.28.2",
1081+ "@esbuild/linux-ppc64": "0.28.2",
1082+ "@esbuild/linux-riscv64": "0.28.2",
1083+ "@esbuild/linux-s390x": "0.28.2",
1084+ "@esbuild/linux-x64": "0.28.2",
1085+ "@esbuild/netbsd-arm64": "0.28.2",
1086+ "@esbuild/netbsd-x64": "0.28.2",
1087+ "@esbuild/openbsd-arm64": "0.28.2",
1088+ "@esbuild/openbsd-x64": "0.28.2",
1089+ "@esbuild/openharmony-arm64": "0.28.2",
1090+ "@esbuild/sunos-x64": "0.28.2",
1091+ "@esbuild/win32-arm64": "0.28.2",
1092+ "@esbuild/win32-ia32": "0.28.2",
1093+ "@esbuild/win32-x64": "0.28.2"
1094+ }
1095+ },
1096+ "node_modules/estree-walker": {
1097+ "version": "3.0.3",
1098+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
1099+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
1100+ "dev": true,
1101+ "license": "MIT",
1102+ "dependencies": {
1103+ "@types/estree": "^1.0.0"
1104+ }
1105+ },
1106+ "node_modules/expect-type": {
1107+ "version": "1.4.0",
1108+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
1109+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
1110+ "dev": true,
1111+ "license": "Apache-2.0",
1112+ "engines": {
1113+ "node": ">=12.0.0"
1114+ }
1115+ },
1116+ "node_modules/fdir": {
1117+ "version": "6.5.0",
1118+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
1119+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
1120+ "dev": true,
1121+ "license": "MIT",
1122+ "engines": {
1123+ "node": ">=12.0.0"
1124+ },
1125+ "peerDependencies": {
1126+ "picomatch": "^3 || ^4"
1127+ },
1128+ "peerDependenciesMeta": {
1129+ "picomatch": {
1130+ "optional": true
1131+ }
1132+ }
1133+ },
1134+ "node_modules/fsevents": {
1135+ "version": "2.3.3",
1136+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1137+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1138+ "dev": true,
1139+ "hasInstallScript": true,
1140+ "license": "MIT",
1141+ "optional": true,
1142+ "os": [
1143+ "darwin"
1144+ ],
1145+ "engines": {
1146+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1147+ }
1148+ },
1149+ "node_modules/js-tokens": {
1150+ "version": "9.0.1",
1151+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
1152+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
1153+ "dev": true,
1154+ "license": "MIT"
1155+ },
1156+ "node_modules/loupe": {
1157+ "version": "3.2.1",
1158+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
1159+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
1160+ "dev": true,
1161+ "license": "MIT"
1162+ },
1163+ "node_modules/magic-string": {
1164+ "version": "0.30.21",
1165+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
1166+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
1167+ "dev": true,
1168+ "license": "MIT",
1169+ "dependencies": {
1170+ "@jridgewell/sourcemap-codec": "^1.5.5"
1171+ }
1172+ },
1173+ "node_modules/ms": {
1174+ "version": "2.1.3",
1175+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1176+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1177+ "dev": true,
1178+ "license": "MIT"
1179+ },
1180+ "node_modules/nanoid": {
1181+ "version": "3.3.20",
1182+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
1183+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
1184+ "dev": true,
1185+ "funding": [
1186+ {
1187+ "type": "github",
1188+ "url": "https://github.com/sponsors/ai"
1189+ }
1190+ ],
1191+ "license": "MIT",
1192+ "bin": {
1193+ "nanoid": "bin/nanoid.cjs"
1194+ },
1195+ "engines": {
1196+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1197+ }
1198+ },
1199+ "node_modules/pathe": {
1200+ "version": "2.0.3",
1201+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
1202+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
1203+ "dev": true,
1204+ "license": "MIT"
1205+ },
1206+ "node_modules/pathval": {
1207+ "version": "2.0.1",
1208+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
1209+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
1210+ "dev": true,
1211+ "license": "MIT",
1212+ "engines": {
1213+ "node": ">= 14.16"
1214+ }
1215+ },
1216+ "node_modules/picocolors": {
1217+ "version": "1.1.1",
1218+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1219+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1220+ "dev": true,
1221+ "license": "ISC"
1222+ },
1223+ "node_modules/picomatch": {
1224+ "version": "4.0.7",
1225+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
1226+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
1227+ "dev": true,
1228+ "license": "MIT",
1229+ "engines": {
1230+ "node": ">=12"
1231+ },
1232+ "funding": {
1233+ "url": "https://github.com/sponsors/jonschlinkert"
1234+ }
1235+ },
1236+ "node_modules/postcss": {
1237+ "version": "8.5.29",
1238+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
1239+ "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
1240+ "dev": true,
1241+ "funding": [
1242+ {
1243+ "type": "opencollective",
1244+ "url": "https://opencollective.com/postcss/"
1245+ },
1246+ {
1247+ "type": "tidelift",
1248+ "url": "https://tidelift.com/funding/github/npm/postcss"
1249+ },
1250+ {
1251+ "type": "github",
1252+ "url": "https://github.com/sponsors/ai"
1253+ }
1254+ ],
1255+ "license": "MIT",
1256+ "dependencies": {
1257+ "nanoid": "^3.3.19",
1258+ "picocolors": "^1.1.1",
1259+ "source-map-js": "^1.2.2"
1260+ },
1261+ "engines": {
1262+ "node": "^10 || ^12 || >=14"
1263+ }
1264+ },
1265+ "node_modules/rollup": {
1266+ "version": "4.64.1",
1267+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.1.tgz",
1268+ "integrity": "sha512-7s4V+3J8ux75WNj9qwSJnrWmlCmZ2YHr2fojtHy5RiIMvhJv6DGe7HKKz7L6G1sq1Eg1fLC8SjYtAtDis1F4CQ==",
1269+ "dev": true,
1270+ "license": "MIT",
1271+ "dependencies": {
1272+ "@types/estree": "1.0.9"
1273+ },
1274+ "bin": {
1275+ "rollup": "dist/bin/rollup"
1276+ },
1277+ "engines": {
1278+ "node": ">=18.0.0",
1279+ "npm": ">=8.0.0"
1280+ },
1281+ "optionalDependencies": {
1282+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
1283+ "@rollup/rollup-android-arm-eabi": "4.64.1",
1284+ "@rollup/rollup-android-arm64": "4.64.1",
1285+ "@rollup/rollup-darwin-arm64": "4.64.1",
1286+ "@rollup/rollup-darwin-x64": "4.64.1",
1287+ "@rollup/rollup-freebsd-arm64": "4.64.1",
1288+ "@rollup/rollup-freebsd-x64": "4.64.1",
1289+ "@rollup/rollup-linux-arm-gnueabihf": "4.64.1",
1290+ "@rollup/rollup-linux-arm-musleabihf": "4.64.1",
1291+ "@rollup/rollup-linux-arm64-gnu": "4.64.1",
1292+ "@rollup/rollup-linux-arm64-musl": "4.64.1",
1293+ "@rollup/rollup-linux-loong64-gnu": "4.64.1",
1294+ "@rollup/rollup-linux-loong64-musl": "4.64.1",
1295+ "@rollup/rollup-linux-ppc64-gnu": "4.64.1",
1296+ "@rollup/rollup-linux-ppc64-musl": "4.64.1",
1297+ "@rollup/rollup-linux-riscv64-gnu": "4.64.1",
1298+ "@rollup/rollup-linux-riscv64-musl": "4.64.1",
1299+ "@rollup/rollup-linux-s390x-gnu": "4.64.1",
1300+ "@rollup/rollup-linux-x64-gnu": "4.64.1",
1301+ "@rollup/rollup-linux-x64-musl": "4.64.1",
1302+ "@rollup/rollup-openbsd-x64": "4.64.1",
1303+ "@rollup/rollup-openharmony-arm64": "4.64.1",
1304+ "@rollup/rollup-win32-arm64-msvc": "4.64.1",
1305+ "@rollup/rollup-win32-ia32-msvc": "4.64.1",
1306+ "@rollup/rollup-win32-x64-gnu": "4.64.1",
1307+ "@rollup/rollup-win32-x64-msvc": "4.64.1",
1308+ "fsevents": "~2.3.2"
1309+ }
1310+ },
1311+ "node_modules/siginfo": {
1312+ "version": "2.0.0",
1313+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
1314+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
1315+ "dev": true,
1316+ "license": "ISC"
1317+ },
1318+ "node_modules/source-map-js": {
1319+ "version": "1.2.2",
1320+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
1321+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
1322+ "dev": true,
1323+ "license": "BSD-3-Clause",
1324+ "engines": {
1325+ "node": ">=0.10.0"
1326+ }
1327+ },
1328+ "node_modules/stackback": {
1329+ "version": "0.0.2",
1330+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
1331+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
1332+ "dev": true,
1333+ "license": "MIT"
1334+ },
1335+ "node_modules/std-env": {
1336+ "version": "3.10.0",
1337+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
1338+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
1339+ "dev": true,
1340+ "license": "MIT"
1341+ },
1342+ "node_modules/strip-literal": {
1343+ "version": "3.1.0",
1344+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
1345+ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
1346+ "dev": true,
1347+ "license": "MIT",
1348+ "dependencies": {
1349+ "js-tokens": "^9.0.1"
1350+ },
1351+ "funding": {
1352+ "url": "https://github.com/sponsors/antfu"
1353+ }
1354+ },
1355+ "node_modules/tinybench": {
1356+ "version": "2.9.0",
1357+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
1358+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
1359+ "dev": true,
1360+ "license": "MIT"
1361+ },
1362+ "node_modules/tinyexec": {
1363+ "version": "0.3.2",
1364+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
1365+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
1366+ "dev": true,
1367+ "license": "MIT"
1368+ },
1369+ "node_modules/tinyglobby": {
1370+ "version": "0.2.17",
1371+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
1372+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
1373+ "dev": true,
1374+ "license": "MIT",
1375+ "dependencies": {
1376+ "fdir": "^6.5.0",
1377+ "picomatch": "^4.0.4"
1378+ },
1379+ "engines": {
1380+ "node": ">=12.0.0"
1381+ },
1382+ "funding": {
1383+ "url": "https://github.com/sponsors/SuperchupuDev"
1384+ }
1385+ },
1386+ "node_modules/tinypool": {
1387+ "version": "1.1.1",
1388+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
1389+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
1390+ "dev": true,
1391+ "license": "MIT",
1392+ "engines": {
1393+ "node": "^18.0.0 || >=20.0.0"
1394+ }
1395+ },
1396+ "node_modules/tinyrainbow": {
1397+ "version": "2.0.0",
1398+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
1399+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
1400+ "dev": true,
1401+ "license": "MIT",
1402+ "engines": {
1403+ "node": ">=14.0.0"
1404+ }
1405+ },
1406+ "node_modules/tinyspy": {
1407+ "version": "4.0.6",
1408+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz",
1409+ "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==",
1410+ "dev": true,
1411+ "license": "MIT",
1412+ "engines": {
1413+ "node": ">=14.0.0"
1414+ }
1415+ },
1416+ "node_modules/typescript": {
1417+ "version": "5.9.3",
1418+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1419+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1420+ "dev": true,
1421+ "license": "Apache-2.0",
1422+ "bin": {
1423+ "tsc": "bin/tsc",
1424+ "tsserver": "bin/tsserver"
1425+ },
1426+ "engines": {
1427+ "node": ">=14.17"
1428+ }
1429+ },
1430+ "node_modules/vite": {
1431+ "version": "7.3.7",
1432+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz",
1433+ "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==",
1434+ "dev": true,
1435+ "license": "MIT",
1436+ "dependencies": {
1437+ "esbuild": "^0.27.0 || ^0.28.0",
1438+ "fdir": "^6.5.0",
1439+ "picomatch": "^4.0.3",
1440+ "postcss": "^8.5.6",
1441+ "rollup": "^4.43.0",
1442+ "tinyglobby": "^0.2.15"
1443+ },
1444+ "bin": {
1445+ "vite": "bin/vite.js"
1446+ },
1447+ "engines": {
1448+ "node": "^20.19.0 || >=22.12.0"
1449+ },
1450+ "funding": {
1451+ "url": "https://github.com/vitejs/vite?sponsor=1"
1452+ },
1453+ "optionalDependencies": {
1454+ "fsevents": "~2.3.3"
1455+ },
1456+ "peerDependencies": {
1457+ "@types/node": "^20.19.0 || >=22.12.0",
1458+ "jiti": ">=1.21.0",
1459+ "less": "^4.0.0",
1460+ "lightningcss": "^1.21.0",
1461+ "sass": "^1.70.0",
1462+ "sass-embedded": "^1.70.0",
1463+ "stylus": ">=0.54.8",
1464+ "sugarss": "^5.0.0",
1465+ "terser": "^5.16.0",
1466+ "tsx": "^4.8.1",
1467+ "yaml": "^2.4.2"
1468+ },
1469+ "peerDependenciesMeta": {
1470+ "@types/node": {
1471+ "optional": true
1472+ },
1473+ "jiti": {
1474+ "optional": true
1475+ },
1476+ "less": {
1477+ "optional": true
1478+ },
1479+ "lightningcss": {
1480+ "optional": true
1481+ },
1482+ "sass": {
1483+ "optional": true
1484+ },
1485+ "sass-embedded": {
1486+ "optional": true
1487+ },
1488+ "stylus": {
1489+ "optional": true
1490+ },
1491+ "sugarss": {
1492+ "optional": true
1493+ },
1494+ "terser": {
1495+ "optional": true
1496+ },
1497+ "tsx": {
1498+ "optional": true
1499+ },
1500+ "yaml": {
1501+ "optional": true
1502+ }
1503+ }
1504+ },
1505+ "node_modules/vite-node": {
1506+ "version": "3.2.4",
1507+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
1508+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
1509+ "dev": true,
1510+ "license": "MIT",
1511+ "dependencies": {
1512+ "cac": "^6.7.14",
1513+ "debug": "^4.4.1",
1514+ "es-module-lexer": "^1.7.0",
1515+ "pathe": "^2.0.3",
1516+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
1517+ },
1518+ "bin": {
1519+ "vite-node": "vite-node.mjs"
1520+ },
1521+ "engines": {
1522+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
1523+ },
1524+ "funding": {
1525+ "url": "https://opencollective.com/vitest"
1526+ }
1527+ },
1528+ "node_modules/vitest": {
1529+ "version": "3.2.7",
1530+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
1531+ "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
1532+ "dev": true,
1533+ "license": "MIT",
1534+ "dependencies": {
1535+ "@types/chai": "^5.2.2",
1536+ "@vitest/expect": "3.2.7",
1537+ "@vitest/mocker": "3.2.7",
1538+ "@vitest/pretty-format": "^3.2.7",
1539+ "@vitest/runner": "3.2.7",
1540+ "@vitest/snapshot": "3.2.7",
1541+ "@vitest/spy": "3.2.7",
1542+ "@vitest/utils": "3.2.7",
1543+ "chai": "^5.2.0",
1544+ "debug": "^4.4.1",
1545+ "expect-type": "^1.2.1",
1546+ "magic-string": "^0.30.17",
1547+ "pathe": "^2.0.3",
1548+ "picomatch": "^4.0.2",
1549+ "std-env": "^3.9.0",
1550+ "tinybench": "^2.9.0",
1551+ "tinyexec": "^0.3.2",
1552+ "tinyglobby": "^0.2.14",
1553+ "tinypool": "^1.1.1",
1554+ "tinyrainbow": "^2.0.0",
1555+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
1556+ "vite-node": "3.2.4",
1557+ "why-is-node-running": "^2.3.0"
1558+ },
1559+ "bin": {
1560+ "vitest": "vitest.mjs"
1561+ },
1562+ "engines": {
1563+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
1564+ },
1565+ "funding": {
1566+ "url": "https://opencollective.com/vitest"
1567+ },
1568+ "peerDependencies": {
1569+ "@edge-runtime/vm": "*",
1570+ "@types/debug": "^4.1.12",
1571+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
1572+ "@vitest/browser": "3.2.7",
1573+ "@vitest/ui": "3.2.7",
1574+ "happy-dom": "*",
1575+ "jsdom": "*"
1576+ },
1577+ "peerDependenciesMeta": {
1578+ "@edge-runtime/vm": {
1579+ "optional": true
1580+ },
1581+ "@types/debug": {
1582+ "optional": true
1583+ },
1584+ "@types/node": {
1585+ "optional": true
1586+ },
1587+ "@vitest/browser": {
1588+ "optional": true
1589+ },
1590+ "@vitest/ui": {
1591+ "optional": true
1592+ },
1593+ "happy-dom": {
1594+ "optional": true
1595+ },
1596+ "jsdom": {
1597+ "optional": true
1598+ }
1599+ }
1600+ },
1601+ "node_modules/why-is-node-running": {
1602+ "version": "2.3.0",
1603+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
1604+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
1605+ "dev": true,
1606+ "license": "MIT",
1607+ "dependencies": {
1608+ "siginfo": "^2.0.0",
1609+ "stackback": "0.0.2"
1610+ },
1611+ "bin": {
1612+ "why-is-node-running": "cli.js"
1613+ },
1614+ "engines": {
1615+ "node": ">=8"
1616+ }
1617+ }
1618+ }
1619+}
package.jsonadded+18−0View file
@@ -0,0 +1,18 @@
1+{
2+ "name": "toy-instruments",
3+ "private": true,
4+ "version": "0.1.0",
5+ "type": "module",
6+ "scripts": {
7+ "dev": "vite",
8+ "build": "tsc --noEmit && vite build",
9+ "preview": "vite preview",
10+ "test": "vitest run",
11+ "typecheck": "tsc --noEmit"
12+ },
13+ "devDependencies": {
14+ "typescript": "^5.9.0",
15+ "vite": "^7.1.0",
16+ "vitest": "^3.2.0"
17+ }
18+}
src/instruments/bead-string/model.tsadded+256−0View file
@@ -0,0 +1,256 @@
1+import type { Anchor, BeadSystem, Excitation } from '../../sim/types';
2+import { tridiagEigenvalues } from '../../sim/tridiag';
3+
4+export type EndType = 'clamped' | 'sliding' | 'free';
5+export type ExcitationType = 'pluck' | 'strike' | 'hammer';
6+
7+export interface StringParams {
8+ // geometry
9+ n: number; // beads
10+ spacing: number; // m, bead spacing at equilibrium (also bead-to-wall)
11+ // properties
12+ mass: number; // kg per bead
13+ k: number; // spring stiffness, N/m
14+ tension: number; // N, equilibrium tension (sets rest length L0 = spacing - T/k)
15+ leftEnd: EndType;
16+ rightEnd: EndType;
17+ pins: string; // 1-based bead numbers, comma separated
18+ // damping (0 = off)
19+ t60Low: number; // s
20+ fLow: number; // Hz
21+ t60High: number; // s
22+ fHigh: number; // Hz
23+ // excitation
24+ excitation: ExcitationType;
25+ excBead: number; // 1-based
26+ excWidth: number; // beads (Gaussian sigma); 0 = one bead
27+ pluckAmp: number; // m
28+ strikeVel: number; // m/s
29+ hammerForce: number; // N, peak total
30+ hammerMs: number; // ms
31+ excAngle: number; // degrees from y toward z
32+ // pickup
33+ pickupMode: 'bead' | 'sum';
34+ pickupBead: number; // 1-based
35+ pickupAxis: 'y' | 'z' | 'x';
36+ // simulation
37+ duration: number;
38+ fs: number;
39+ cfl: number;
40+ frameRate: number;
41+}
42+
43+export function parsePins(s: string, n: number): number[] {
44+ return s
45+ .split(/[\s,;]+/)
46+ .filter((t) => t.length > 0)
47+ .map((t) => Math.round(Number(t)) - 1)
48+ .filter((i) => Number.isFinite(i) && i >= 0 && i < n);
49+}
50+
51+export interface Derived {
52+ restLength: number;
53+ prestretch: number; // (L - L0) / L0
54+ length: number; // wall to wall (m)
55+ linearDensity: number; // kg/m
56+ waveSpeedT: number; // transverse, continuum limit (m/s)
57+ waveSpeedL: number; // longitudinal, continuum limit (m/s)
58+ transverseHz: number[]; // linear modes (lowest first)
59+ longitudinalHz: number[];
60+ sigma0: number;
61+ sigma1: number;
62+ dashpot: number;
63+ warnings: string[];
64+}
65+
66+// Linear damping sigma(omega) = sigma0 + sigma1 omega^2 for transverse modes,
67+// fitted to two decay times T60 = 3 ln(10) / sigma.
68+export function dampingFromT60(p: StringParams): { sigma0: number; sigma1: number; note?: string } {
69+ if (!(p.t60Low > 0) && !(p.t60High > 0)) return { sigma0: 0, sigma1: 0 };
70+ const ln1000 = 3 * Math.LN10;
71+ if (!(p.t60High > 0) || !(p.fHigh > p.fLow)) return { sigma0: ln1000 / p.t60Low, sigma1: 0 };
72+ if (!(p.t60Low > 0)) {
73+ const w = 2 * Math.PI * p.fHigh;
74+ return { sigma0: 0, sigma1: ln1000 / p.t60High / (w * w) };
75+ }
76+ const s1 = ln1000 / p.t60Low;
77+ const s2 = ln1000 / p.t60High;
78+ const w1 = 2 * Math.PI * p.fLow;
79+ const w2 = 2 * Math.PI * p.fHigh;
80+ let sigma1 = (s2 - s1) / (w2 * w2 - w1 * w1);
81+ let sigma0 = s1 - sigma1 * w1 * w1;
82+ let note: string | undefined;
83+ if (sigma1 < 0) {
84+ sigma1 = 0;
85+ sigma0 = s1;
86+ note = 'High-frequency T60 is longer than low-frequency T60; using frequency-independent damping.';
87+ } else if (sigma0 < 0) {
88+ sigma0 = 0;
89+ sigma1 = s1 / (w1 * w1);
90+ note = 'Decay times imply negative mass damping; clamped to zero.';
91+ }
92+ return { sigma0, sigma1, note };
93+}
94+
95+// Linearized modes about the straight equilibrium. Transverse: each spring acts
96+// with stiffness T/L (zero to a sliding ring, which follows the bead).
97+// Longitudinal: stiffness k (to clamped or sliding walls; nothing to a free end).
98+function linearModes(p: StringParams, count: number): { trans: number[]; long: number[] } {
99+ const pinned = new Set(parsePins(p.pins, p.n));
100+ const idx: number[] = [];
101+ for (let i = 0; i < p.n; i++) if (!pinned.has(i)) idx.push(i);
102+ const build = (kSpring: number, wallLeft: number, wallRight: number) => {
103+ const d = new Float64Array(idx.length);
104+ const e = new Float64Array(Math.max(0, idx.length - 1));
105+ idx.forEach((i, r) => {
106+ let diag = 0;
107+ diag += i === 0 ? wallLeft : kSpring;
108+ diag += i === p.n - 1 ? wallRight : kSpring;
109+ d[r] = diag / p.mass;
110+ if (r < idx.length - 1 && idx[r + 1] === i + 1) e[r] = -kSpring / p.mass;
111+ });
112+ return tridiagEigenvalues(d, e, count)
113+ .filter((l) => l > 1e-9 * Math.max(...d, 1))
114+ .map((l) => Math.sqrt(l) / (2 * Math.PI));
115+ };
116+ const kt = p.tension / p.spacing;
117+ const tWall = (end: EndType) => (end === 'clamped' ? kt : 0);
118+ const lWall = (end: EndType) => (end === 'free' ? 0 : p.k);
119+ return {
120+ trans: build(kt, tWall(p.leftEnd), tWall(p.rightEnd)),
121+ long: build(p.k, lWall(p.leftEnd), lWall(p.rightEnd)),
122+ };
123+}
124+
125+export function derive(p: StringParams): Derived {
126+ const warnings: string[] = [];
127+ const restLength = p.spacing - p.tension / p.k;
128+ if (!(restLength > 0)) warnings.push('Tension too high for this stiffness: rest length L0 = L - T/k is not positive.');
129+ if (p.tension > 0 && (p.leftEnd === 'free' || p.rightEnd === 'free'))
130+ warnings.push('A free end cannot hold tension: the string is not in equilibrium and will contract. Use sliding ends (tension kept, free transverse motion) or set T = 0.');
131+ if (p.tension < 0) warnings.push('Negative tension (compression): the straight string is unstable to buckling.');
132+ const linearDensity = p.mass / p.spacing;
133+ const { sigma0, sigma1, note } = dampingFromT60(p);
134+ if (note) warnings.push(note);
135+ const kt = p.tension > 0 ? p.tension / p.spacing : p.k;
136+ const modes = linearModes(p, 24);
137+ return {
138+ restLength,
139+ prestretch: p.spacing / restLength - 1,
140+ length: (p.n + 1) * p.spacing,
141+ linearDensity,
142+ waveSpeedT: Math.sqrt(Math.max(p.tension, 0) / linearDensity),
143+ waveSpeedL: Math.sqrt((p.k * p.spacing) / linearDensity),
144+ transverseHz: modes.trans,
145+ longitudinalHz: modes.long,
146+ sigma0,
147+ sigma1,
148+ dashpot: 2 * sigma1 * kt,
149+ warnings,
150+ };
151+}
152+
153+export function buildSystem(p: StringParams, d: Derived): BeadSystem {
154+ const n = p.n;
155+ const rest = new Float64Array(3 * n);
156+ for (let i = 0; i < n; i++) rest[3 * i] = (i + 1) * p.spacing;
157+ const mass = new Float64Array(n).fill(p.mass);
158+ const pinned = new Uint8Array(n);
159+ for (const i of parsePins(p.pins, n)) pinned[i] = 1;
160+ const anchors: Anchor[] = [];
161+ const si: number[] = [];
162+ const sj: number[] = [];
163+ const addEnd = (end: EndType, x: number, bead: number) => {
164+ if (end === 'free') return;
165+ anchors.push({ pos: [x, 0, 0], slideBead: end === 'sliding' ? bead : -1 });
166+ const node = n + anchors.length - 1;
167+ // anchor on the left of bead 0 is si; keep orientation left -> right
168+ if (bead === 0) {
169+ si.push(node);
170+ sj.push(0);
171+ } else {
172+ si.push(bead);
173+ sj.push(node);
174+ }
175+ };
176+ addEnd(p.leftEnd, 0, 0);
177+ for (let i = 0; i < n - 1; i++) {
178+ si.push(i);
179+ sj.push(i + 1);
180+ }
181+ addEnd(p.rightEnd, (n + 1) * p.spacing, n - 1);
182+ const ns = si.length;
183+ return {
184+ n,
185+ rest,
186+ mass,
187+ pinned,
188+ anchors,
189+ si: Int32Array.from(si),
190+ sj: Int32Array.from(sj),
191+ sk: new Float64Array(ns).fill(p.k),
192+ sL0: new Float64Array(ns).fill(d.restLength),
193+ sc: new Float64Array(ns).fill(d.dashpot),
194+ sigma0: d.sigma0,
195+ };
196+}
197+
198+function gaussianWeights(n: number, center: number, width: number): Float64Array {
199+ const w = new Float64Array(n);
200+ if (!(width > 0)) {
201+ w[center] = 1;
202+ return w;
203+ }
204+ for (let i = 0; i < n; i++) w[i] = Math.exp(-0.5 * ((i - center) / width) ** 2);
205+ return w;
206+}
207+
208+const clampIndex = (i: number, n: number) => Math.min(n - 1, Math.max(0, Math.round(i) - 1));
209+
210+export function buildExcitation(p: StringParams): Excitation {
211+ const n = p.n;
212+ const u0 = new Float64Array(3 * n);
213+ const v0 = new Float64Array(3 * n);
214+ const th = (p.excAngle * Math.PI) / 180;
215+ const dy = Math.cos(th);
216+ const dz = Math.sin(th);
217+ const c = clampIndex(p.excBead, n);
218+ if (p.excitation === 'pluck') {
219+ // triangle from the nearest support on each side (pinned bead or wall) to the plucked bead
220+ const pins = new Set(parsePins(p.pins, n));
221+ let left = -1;
222+ for (let i = c - 1; i >= 0; i--) if (pins.has(i)) { left = i; break; }
223+ let right = n;
224+ for (let i = c + 1; i < n; i++) if (pins.has(i)) { right = i; break; }
225+ for (let i = left + 1; i < right; i++) {
226+ const s = i <= c ? (i - left) / (c - left) : (right - i) / (right - c);
227+ u0[3 * i + 1] = p.pluckAmp * s * dy;
228+ u0[3 * i + 2] = p.pluckAmp * s * dz;
229+ }
230+ return { u0, v0 };
231+ }
232+ const w = gaussianWeights(n, c, p.excWidth);
233+ if (p.excitation === 'strike') {
234+ for (let i = 0; i < n; i++) {
235+ v0[3 * i + 1] = p.strikeVel * w[i] * dy;
236+ v0[3 * i + 2] = p.strikeVel * w[i] * dz;
237+ }
238+ return { u0, v0 };
239+ }
240+ let sum = 0;
241+ for (let i = 0; i < n; i++) sum += w[i];
242+ const shape = new Float64Array(3 * n);
243+ for (let i = 0; i < n; i++) {
244+ shape[3 * i + 1] = (w[i] / sum) * dy;
245+ shape[3 * i + 2] = (w[i] / sum) * dz;
246+ }
247+ return { u0, v0, force: { shape, F0: p.hammerForce, tau: p.hammerMs / 1000 } };
248+}
249+
250+export function buildPickup(p: StringParams): Float64Array {
251+ const w = new Float64Array(3 * p.n);
252+ const axis = p.pickupAxis === 'x' ? 0 : p.pickupAxis === 'y' ? 1 : 2;
253+ if (p.pickupMode === 'sum') for (let i = 0; i < p.n; i++) w[3 * i + axis] = 1;
254+ else w[3 * clampIndex(p.pickupBead, p.n) + axis] = 1;
255+ return w;
256+}
src/instruments/bead-string/page.tsadded+320−0View file
@@ -0,0 +1,320 @@
1+import { renderControls, type Section } from '../../ui/controls';
2+import { LinePlot, css, fmtNum } from '../../ui/plot';
3+import { Spectrogram } from '../../ui/spectrogram';
4+import { ChainView } from '../../ui/chainview';
5+import { spectrum } from '../../ui/fft';
6+import { encodeWav } from '../../ui/wav';
7+import { omegaMaxBound } from '../../sim/solver';
8+import { runInWorker, type Running } from '../../sim/run';
9+import type { SimResult } from '../../sim/types';
10+import { buildExcitation, buildPickup, buildSystem, derive, parsePins, type StringParams } from './model';
11+import { PRESETS } from './presets';
12+
13+const endOptions: [string, string][] = [
14+ ['clamped', 'Clamped (fixed point)'],
15+ ['sliding', 'Sliding ring (free across, holds tension)'],
16+ ['free', 'Free (no support)'],
17+];
18+const isExc = (t: string) => (p: StringParams) => p.excitation === t;
19+
20+const SECTIONS: Section<StringParams>[] = [
21+ {
22+ title: 'Geometry',
23+ params: [
24+ { key: 'n', label: 'Beads', kind: 'int', min: 1, help: 'Number of moving beads; there is one more spring than beads when both ends are supported.' },
25+ { key: 'spacing', label: 'Spacing', kind: 'number', unit: 'mm', scale: 1000, help: 'Equilibrium distance between neighbouring beads (and from the end beads to the walls).' },
26+ ],
27+ },
28+ {
29+ title: 'Properties',
30+ params: [
31+ { key: 'mass', label: 'Bead mass', kind: 'number', unit: 'g', scale: 1000 },
32+ { key: 'k', label: 'Spring stiffness', kind: 'number', unit: 'N/m', help: 'Axial stiffness k. Sets the longitudinal wave speed and, with the mass, the time step.' },
33+ { key: 'tension', label: 'Tension', kind: 'number', unit: 'N', help: 'Equilibrium tension T. Springs are pre-stretched: rest length L0 = spacing − T/k. Sets the transverse wave speed.' },
34+ ],
35+ },
36+ {
37+ title: 'Ends and pins',
38+ params: [
39+ { key: 'leftEnd', label: 'Left end', kind: 'select', options: endOptions },
40+ { key: 'rightEnd', label: 'Right end', kind: 'select', options: endOptions },
41+ { key: 'pins', label: 'Pinned beads', kind: 'text', help: 'Bead numbers (1-based), comma separated. Pinned beads are held fixed, like a finger on a fret.' },
42+ ],
43+ },
44+ {
45+ title: 'Damping (0 = off)',
46+ params: [
47+ { key: 't60Low', label: 'T60 low', kind: 'number', unit: 's', help: 'Time for transverse vibration at the low frequency to decay by 60 dB.' },
48+ { key: 'fLow', label: 'at', kind: 'number', unit: 'Hz' },
49+ { key: 't60High', label: 'T60 high', kind: 'number', unit: 's' },
50+ { key: 'fHigh', label: 'at', kind: 'number', unit: 'Hz' },
51+ ],
52+ },
53+ {
54+ title: 'Excitation',
55+ params: [
56+ {
57+ key: 'excitation',
58+ label: 'Type',
59+ kind: 'select',
60+ options: [
61+ ['pluck', 'Pluck (triangle, released at rest)'],
62+ ['strike', 'Strike (initial velocity)'],
63+ ['hammer', 'Hammer (force pulse)'],
64+ ],
65+ },
66+ { key: 'excBead', label: 'At bead', kind: 'int', min: 1 },
67+ { key: 'excWidth', label: 'Width', kind: 'number', unit: 'beads', show: (p) => p.excitation !== 'pluck', help: 'Gaussian width (standard deviation) in beads; 0 = one bead.' },
68+ { key: 'pluckAmp', label: 'Amplitude', kind: 'number', unit: 'mm', scale: 1000, show: isExc('pluck') },
69+ { key: 'strikeVel', label: 'Velocity', kind: 'number', unit: 'm/s', show: isExc('strike'), help: 'Initial velocity of the centre bead.' },
70+ { key: 'hammerForce', label: 'Peak force', kind: 'number', unit: 'N', show: isExc('hammer'), help: 'Peak of the total force, shared over the beads by the width profile.' },
71+ { key: 'hammerMs', label: 'Duration', kind: 'number', unit: 'ms', show: isExc('hammer'), help: 'Raised-cosine pulse length. Shorter is brighter.' },
72+ { key: 'excAngle', label: 'Direction', kind: 'number', unit: '° y→z', help: '0 = along y; 90 = along z. In between excites both transverse planes.' },
73+ ],
74+ },
75+ {
76+ title: 'Pickup (acceleration)',
77+ params: [
78+ { key: 'pickupMode', label: 'Pickup', kind: 'select', options: [['bead', 'One bead'], ['sum', 'Sum over all beads']] },
79+ { key: 'pickupBead', label: 'Bead', kind: 'int', min: 1, show: (p) => p.pickupMode === 'bead' },
80+ { key: 'pickupAxis', label: 'Component', kind: 'select', options: [['y', 'y (transverse)'], ['z', 'z (transverse)'], ['x', 'x (longitudinal)']] },
81+ ],
82+ },
83+ {
84+ title: 'Simulation',
85+ params: [
86+ { key: 'duration', label: 'Duration', kind: 'number', unit: 's' },
87+ { key: 'fs', label: 'Sample rate', kind: 'select', options: [['44100', '44.1 kHz'], ['48000', '48 kHz'], ['96000', '96 kHz']] },
88+ { key: 'cfl', label: 'Step safety', kind: 'number', help: 'Fraction of the leapfrog stability limit dt = 2/ω_max. 0.5 is a safe default; large-amplitude motion raises ω_max.' },
89+ { key: 'frameRate', label: 'Frame rate', kind: 'number', unit: 'Hz', help: 'How often displacements of all beads are saved for the viewer.' },
90+ ],
91+ },
92+];
93+
94+export function mountBeadString(root: HTMLElement) {
95+ root.innerHTML = `
96+ <div class="instrument">
97+ <aside class="panel">
98+ <a href="#/" class="back">← All instruments</a>
99+ <h1>String of beads</h1>
100+ <label class="ctl preset"><span class="ctl-name">Preset</span><select data-r="preset"></select></label>
101+ <p class="sub" data-r="preset-desc"></p>
102+ <div data-r="controls"></div>
103+ <div class="run-row">
104+ <button class="btn" data-r="run">Run simulation</button>
105+ <button class="btn-small" data-r="cancel" hidden>Cancel</button>
106+ </div>
107+ <progress data-r="progress" max="1" value="0" hidden></progress>
108+ </aside>
109+ <main class="results">
110+ <section class="card"><h2>Setup</h2><div data-r="derived" class="stats"></div><ul data-r="warnings" class="warnings"></ul></section>
111+ <section class="card" data-r="out" hidden>
112+ <h2>Run</h2><div data-r="summary" class="stats"></div>
113+ <div class="audio-row"><audio controls data-r="audio"></audio><a class="btn-small" data-r="download" download="bead-string.wav">Download .wav</a></div>
114+ <p class="note" data-r="audio-note"></p>
115+ </section>
116+ <section class="card" data-r="out" hidden>
117+ <div class="card-head"><h2>Pickup acceleration</h2>
118+ <div class="seg" data-r="wave-range"><button data-v="0" class="on">Full</button><button data-v="0.05">First 50 ms</button><button data-v="0.005">First 5 ms</button></div></div>
119+ <div class="canvas-wrap"><canvas data-r="wave"></canvas></div>
120+ </section>
121+ <section class="card" data-r="out" hidden>
122+ <div class="card-head"><h2>Spectrogram</h2>
123+ <label class="inline">up to <select data-r="fmax"><option>1000</option><option>2500</option><option selected>5000</option><option>10000</option><option>24000</option></select> Hz</label></div>
124+ <div class="canvas-wrap tall"><canvas data-r="spec"></canvas></div>
125+ </section>
126+ <section class="card" data-r="out" hidden>
127+ <div class="card-head"><h2>Spectrum</h2>
128+ <div class="legend"><span><i class="sw" style="background:var(--series-1)"></i>pickup signal</span><span><i class="dash" style="border-color:var(--series-1)"></i>linear transverse modes</span><span><i class="dash" style="border-color:var(--series-2)"></i>linear longitudinal modes</span></div></div>
129+ <div class="canvas-wrap"><canvas data-r="spectrum"></canvas></div>
130+ </section>
131+ <section class="card" data-r="out" hidden><h2>Displacements</h2><div data-r="viewer"></div></section>
132+ <section class="card" data-r="out" hidden>
133+ <h2>Energy</h2>
134+ <div class="canvas-wrap short"><canvas data-r="energy"></canvas></div>
135+ <p class="note">Kinetic plus spring energy, minus the energy stored by pre-tension at equilibrium. Without damping or forcing it should stay flat; the run summary gives the error of the energy balance (energy + dissipated − work in − initial).</p>
136+ </section>
137+ </main>
138+ </div>`;
139+ const q = <T extends HTMLElement>(r: string) => root.querySelector(`[data-r="${r}"]`) as T;
140+ const presetSel = q<HTMLSelectElement>('preset');
141+ PRESETS.forEach((p, i) => presetSel.add(new Option(p.name, String(i))));
142+ const presetDesc = q<HTMLParagraphElement>('preset-desc');
143+ const runBtn = q<HTMLButtonElement>('run');
144+ const cancelBtn = q<HTMLButtonElement>('cancel');
145+ const progress = q<HTMLProgressElement>('progress');
146+
147+ const controls = renderControls(q('controls'), SECTIONS, PRESETS[0].params, () => updateDerived());
148+ presetDesc.textContent = PRESETS[0].description;
149+ presetSel.addEventListener('change', () => {
150+ const pr = PRESETS[Number(presetSel.value)];
151+ presetDesc.textContent = pr.description;
152+ controls.set(pr.params);
153+ });
154+
155+ const wave = new LinePlot(q('wave'));
156+ const spec = new Spectrogram(q('spec'));
157+ const specPlot = new LinePlot(q('spectrum'));
158+ const energyPlot = new LinePlot(q('energy'));
159+ const viewer = new ChainView(q('viewer'));
160+ let waveRange = 0;
161+ let last: { p: StringParams; r: SimResult } | null = null;
162+ let running: Running | null = null;
163+ let audioUrl: string | null = null;
164+
165+ function updateDerived() {
166+ const p = controls.get();
167+ const d = derive(p);
168+ const tr = d.transverseHz;
169+ const rows: [string, string][] = [
170+ ['Length (wall to wall)', `${fmtNum(d.length)} m`],
171+ ['Rest length L0', `${fmtNum(d.restLength * 1000)} mm (pre-stretch ${fmtNum(d.prestretch * 100)}%)`],
172+ ['Linear density', `${fmtNum(d.linearDensity * 1000)} g/m`],
173+ ['Wave speed (transverse, longitudinal)', `${fmtNum(d.waveSpeedT)}, ${fmtNum(d.waveSpeedL)} m/s`],
174+ ['Lowest transverse mode', tr.length ? `${fmtNum(tr[0])} Hz` : 'none'],
175+ ['Overtone ratios', tr.slice(1, 7).map((f) => fmtNum(f / tr[0])).join(', ') || '–'],
176+ ['Lowest longitudinal mode', d.longitudinalHz.length ? `${fmtNum(d.longitudinalHz[0])} Hz` : 'none'],
177+ ];
178+ const warnings = [...d.warnings];
179+ if (d.restLength > 0) {
180+ const sys = buildSystem(p, d);
181+ const wmax = omegaMaxBound(sys);
182+ const spp = Math.max(1, Math.ceil(1 / (p.fs * ((p.cfl * 2) / wmax))));
183+ rows.push(['Highest frequency bound', `${fmtNum(wmax / (2 * Math.PI))} Hz`]);
184+ rows.push(['Time step', `${fmtNum(1 / (p.fs * spp))} s (${spp} per sample)`]);
185+ const work = spp * p.fs * p.duration * sys.si.length;
186+ rows.push(['Spring updates', fmtNum(work)]);
187+ const frameMB = (Math.round(p.duration * p.frameRate) * 3 * p.n * 4) / 1e6;
188+ if (frameMB > 400) warnings.push(`Displacement frames would take ${fmtNum(frameMB)} MB; lower the frame rate or duration.`);
189+ if (work > 3e9) warnings.push('This run is large (more than 3e9 spring updates) and may take minutes.');
190+ }
191+ const pins = parsePins(p.pins, p.n);
192+ if (pins.includes(Math.round(p.excBead) - 1)) warnings.push('The excited bead is pinned.');
193+ if (p.pickupMode === 'bead' && pins.includes(Math.round(p.pickupBead) - 1)) warnings.push('The pickup bead is pinned: its acceleration is zero.');
194+ if (tr.length && tr[0] < 20) warnings.push(`The lowest mode (${fmtNum(tr[0])} Hz) is below hearing; you will mostly hear overtones.`);
195+ q('derived').innerHTML = rows.map(([k, v]) => `<div><span>${k}</span><b>${v}</b></div>`).join('');
196+ q('warnings').innerHTML = warnings.map((w) => `<li>${w}</li>`).join('');
197+ runBtn.disabled = !(d.restLength > 0) || !(p.n >= 1);
198+ }
199+ updateDerived();
200+
201+ q('wave-range').addEventListener('click', (e) => {
202+ const b = (e.target as HTMLElement).closest('button');
203+ if (!b) return;
204+ waveRange = Number(b.dataset.v);
205+ q('wave-range').querySelectorAll('button').forEach((x) => x.classList.toggle('on', x === b));
206+ drawWave();
207+ });
208+ q<HTMLSelectElement>('fmax').addEventListener('change', (e) => {
209+ spec.fmax = Number((e.target as HTMLSelectElement).value);
210+ spec.draw();
211+ drawSpectrum();
212+ });
213+
214+ function drawWave() {
215+ if (!last) return;
216+ const { r } = last;
217+ const t = new Float64Array(r.audio.length);
218+ for (let i = 0; i < t.length; i++) t[i] = i / r.fs;
219+ wave.set([{ x: t, y: r.audio, color: css('--series-1'), label: 'a' }], {
220+ xLabel: 'time (s)',
221+ yLabel: 'acceleration (m/s²)',
222+ xRange: waveRange ? [0, Math.min(waveRange, t[t.length - 1])] : undefined,
223+ });
224+ }
225+
226+ function drawSpectrum() {
227+ if (!last) return;
228+ const { p, r } = last;
229+ const d = derive(p);
230+ const s = spectrum(r.audio, r.fs);
231+ let top = -Infinity;
232+ for (const v of s.db) top = Math.max(top, v);
233+ const db = s.db.map((v) => Math.max(v - top, -100));
234+ const fmax = Math.min(spec.fmax, r.fs / 2);
235+ const markers = [
236+ ...d.transverseHz.map((f, i) => ({ x: f, color: css('--series-1'), label: `transverse mode ${i + 1}: ${fmtNum(f)} Hz` })),
237+ ...d.longitudinalHz.map((f, i) => ({ x: f, color: css('--series-2'), label: `longitudinal mode ${i + 1}: ${fmtNum(f)} Hz` })),
238+ ];
239+ specPlot.set([{ x: s.freq, y: db, color: css('--series-1'), label: 'level (dB)' }], {
240+ xLabel: 'frequency (Hz)',
241+ yLabel: 'level (dB re max)',
242+ xRange: [0, fmax],
243+ yRange: [-100, 5],
244+ markers,
245+ });
246+ }
247+
248+ function show(p: StringParams, r: SimResult) {
249+ last = { p, r };
250+ root.querySelectorAll<HTMLElement>('[data-r="out"]').forEach((e) => (e.hidden = false));
251+ const d = derive(p);
252+ let peak = 0;
253+ for (const v of r.audio) peak = Math.max(peak, Math.abs(v));
254+ const emax = Math.max(...r.energy);
255+ let bal = 0;
256+ for (const b of r.balance) bal = Math.max(bal, Math.abs(b));
257+ const rt = p.duration / r.wallSeconds;
258+ q('summary').innerHTML = [
259+ ['Time step', `${fmtNum(r.dt)} s, ${r.stepsPerSample} per sample`],
260+ ['Wall time', `${fmtNum(r.wallSeconds)} s (${rt >= 1 ? fmtNum(rt) + '× faster than' : fmtNum(1 / rt) + '× slower than'} real time)`],
261+ ['Energy balance error', emax > 0 ? `${fmtNum((bal / emax) * 100)}% of peak energy` : '–'],
262+ ['Largest spring strain', `${fmtNum(r.maxStretch * 100)}% (pre-stretch ${fmtNum(d.prestretch * 100)}%)`],
263+ ['Peak pickup acceleration', `${fmtNum(peak)} m/s²`],
264+ ]
265+ .map(([k, v]) => `<div><span>${k}</span><b>${v}</b></div>`)
266+ .join('');
267+ if (audioUrl) URL.revokeObjectURL(audioUrl);
268+ audioUrl = URL.createObjectURL(encodeWav(r.audio, r.fs));
269+ q<HTMLAudioElement>('audio').src = audioUrl;
270+ q<HTMLAnchorElement>('download').href = audioUrl;
271+ q('audio-note').textContent = `16-bit mono, ${r.fs} Hz, normalized so the peak is −1 dBFS.`;
272+ drawWave();
273+ spec.setSignal(r.audio, r.fs);
274+ drawSpectrum();
275+ const sys = buildSystem(p, d);
276+ const rest = new Float64Array(p.n);
277+ for (let i = 0; i < p.n; i++) rest[i] = sys.rest[3 * i];
278+ viewer.set(
279+ {
280+ n: p.n,
281+ restX: rest,
282+ leftWall: p.leftEnd === 'free' ? null : 0,
283+ rightWall: p.rightEnd === 'free' ? null : (p.n + 1) * p.spacing,
284+ slideLeft: p.leftEnd === 'sliding',
285+ slideRight: p.rightEnd === 'sliding',
286+ pinned: sys.pinned,
287+ },
288+ { frames: r.frames, times: r.frameTimes },
289+ );
290+ energyPlot.set([{ x: r.frameTimes, y: r.energy, color: css('--series-1'), label: 'energy (J)' }], {
291+ xLabel: 'time (s)',
292+ yLabel: 'energy (J)',
293+ yRange: [0, emax * 1.05 || 1],
294+ });
295+ }
296+
297+ runBtn.addEventListener('click', async () => {
298+ const p = controls.get();
299+ const d = derive(p);
300+ const sys = buildSystem(p, d);
301+ const exc = buildExcitation(p);
302+ const opt = { duration: p.duration, fs: p.fs, cfl: p.cfl, frameRate: p.frameRate, pickup: buildPickup(p) };
303+ runBtn.disabled = true;
304+ cancelBtn.hidden = false;
305+ progress.hidden = false;
306+ progress.value = 0;
307+ running = runInWorker(sys, exc, opt, (f) => (progress.value = f));
308+ try {
309+ show(p, await running.promise);
310+ } catch (e) {
311+ if ((e as Error).message !== 'cancelled') alert(`Simulation failed: ${(e as Error).message}`);
312+ } finally {
313+ running = null;
314+ runBtn.disabled = false;
315+ cancelBtn.hidden = true;
316+ progress.hidden = true;
317+ }
318+ });
319+ cancelBtn.addEventListener('click', () => running?.cancel());
320+}
src/instruments/bead-string/presets.tsadded+90−0View file
@@ -0,0 +1,90 @@
1+import type { StringParams } from './model';
2+
3+const steel: StringParams = {
4+ n: 50,
5+ spacing: 0.0127, // 0.65 m wall to wall
6+ mass: 5.08e-5, // 4 g/m, like a guitar G string
7+ k: 3.15e6, // EA / L for steel, 0.5 mm diameter
8+ tension: 70,
9+ leftEnd: 'clamped',
10+ rightEnd: 'clamped',
11+ pins: '',
12+ t60Low: 4,
13+ fLow: 100,
14+ t60High: 0.6,
15+ fHigh: 4000,
16+ excitation: 'pluck',
17+ excBead: 10,
18+ excWidth: 1,
19+ pluckAmp: 0.002,
20+ strikeVel: 2,
21+ hammerForce: 20,
22+ hammerMs: 1,
23+ excAngle: 0,
24+ pickupMode: 'bead',
25+ pickupBead: 4,
26+ pickupAxis: 'y',
27+ duration: 2,
28+ fs: 48000,
29+ cfl: 0.5,
30+ frameRate: 2000,
31+};
32+
33+export interface Preset {
34+ name: string;
35+ description: string;
36+ params: StringParams;
37+}
38+
39+export const PRESETS: Preset[] = [
40+ {
41+ name: 'Steel string, plucked',
42+ description: 'Guitar-like: 50 beads, 0.65 m, 70 N, ~102 Hz. Stiff springs, so longitudinal modes sit near 2.4 kHz.',
43+ params: steel,
44+ },
45+ {
46+ name: 'Steel string, fretted at the middle',
47+ description: 'Same string with bead 25 pinned: an octave up, each half rings on its own.',
48+ params: { ...steel, pins: '25', excBead: 6, pickupBead: 3 },
49+ },
50+ {
51+ name: 'Steel string, hammered',
52+ description: '1 ms raised-cosine force near one end, like a piano hammer (no hammer felt or contact).',
53+ params: { ...steel, excitation: 'hammer', excBead: 6, excWidth: 1, hammerForce: 30, hammerMs: 1.5 },
54+ },
55+ {
56+ name: 'Rubber band, big pluck',
57+ description: 'Soft springs, 100% pre-stretch: transverse and longitudinal speeds are close, so a large pluck glides in pitch and couples the two.',
58+ params: {
59+ ...steel,
60+ n: 30,
61+ spacing: 0.01,
62+ mass: 1e-4,
63+ k: 8000,
64+ tension: 40,
65+ pluckAmp: 0.03,
66+ excBead: 8,
67+ t60Low: 1.5,
68+ t60High: 0.3,
69+ },
70+ },
71+ {
72+ name: 'Three beads',
73+ description: 'A very coarse string: three modes only, at inharmonic ratios 1 : 1.85 : 2.41.',
74+ params: {
75+ ...steel,
76+ n: 3,
77+ spacing: 0.1625,
78+ mass: 0.00065,
79+ k: 2.5e5,
80+ excBead: 1,
81+ pickupBead: 1,
82+ frameRate: 4000,
83+ },
84+ },
85+ {
86+ name: 'Whirling string',
87+ description: 'Plucked at 30 degrees out of plane with sliding ends: watch the y and z views and the pitch of each.',
88+ params: { ...steel, n: 40, spacing: 0.016, leftEnd: 'clamped', rightEnd: 'sliding', excAngle: 30, pluckAmp: 0.008, mass: 6.4e-5 },
89+ },
90+];
src/instruments/registry.tsadded+20−0View file
@@ -0,0 +1,20 @@
1+import { mountBeadString } from './bead-string/page';
2+
3+export interface Instrument {
4+ id: string;
5+ name: string;
6+ blurb: string;
7+ mount?: (root: HTMLElement) => void; // absent = not built yet
8+}
9+
10+export const INSTRUMENTS: Instrument[] = [
11+ {
12+ id: 'bead-string',
13+ name: 'String of beads',
14+ blurb: 'Point masses joined by pre-tensioned springs, moving in 3D. Pluck, strike or hammer it; clamp, slide or free the ends; pin beads.',
15+ mount: mountBeadString,
16+ },
17+ { id: 'bead-drum', name: 'Bead drum', blurb: 'A membrane: a tensioned 2D net of beads with a clamped rim.' },
18+ { id: 'bead-bar', name: 'Bar', blurb: 'A stiff chain with bending springs, free ends: xylophone-like overtones.' },
19+ { id: 'bead-plate', name: 'Plate', blurb: 'A 2D net with bending, free or clamped edge.' },
20+];
src/main.tsadded+28−0View file
@@ -0,0 +1,28 @@
1+import './style.css';
2+import { INSTRUMENTS } from './instruments/registry';
3+
4+const app = document.getElementById('app')!;
5+
6+function gallery() {
7+ app.innerHTML = `
8+ <header class="hero"><h1>Toy instruments</h1>
9+ <p class="sub">Idealized physical models you can configure, simulate and listen to. Pick an instrument.</p></header>
10+ <div class="gallery">${INSTRUMENTS.map(
11+ (i) => `<a class="tile${i.mount ? '' : ' soon'}" ${i.mount ? `href="#/${i.id}"` : 'aria-disabled="true"'}>
12+ <h2>${i.name}</h2><p>${i.blurb}</p>${i.mount ? '' : '<span class="badge">Coming later</span>'}</a>`,
13+ ).join('')}</div>`;
14+ document.title = 'Toy instruments';
15+}
16+
17+function route() {
18+ const id = location.hash.replace(/^#\/?/, '');
19+ const inst = INSTRUMENTS.find((i) => i.id === id && i.mount);
20+ window.scrollTo(0, 0);
21+ if (!inst) return gallery();
22+ document.title = `${inst.name} · Toy instruments`;
23+ app.replaceChildren();
24+ inst.mount!(app);
25+}
26+
27+window.addEventListener('hashchange', route);
28+route();
src/sim/run.tsadded+37−0View file
@@ -0,0 +1,37 @@
1+import type { BeadSystem, Excitation, SimOptions, SimResult } from './types';
2+
3+export interface Running {
4+ promise: Promise<SimResult>;
5+ cancel: () => void;
6+}
7+
8+export function runInWorker(sys: BeadSystem, exc: Excitation, opt: SimOptions, onProgress: (f: number) => void): Running {
9+ const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
10+ let reject!: (e: Error) => void;
11+ const promise = new Promise<SimResult>((res, rej) => {
12+ reject = rej;
13+ worker.onmessage = (ev) => {
14+ const m = ev.data;
15+ if (m.type === 'progress') onProgress(m.fraction);
16+ else if (m.type === 'done') {
17+ worker.terminate();
18+ res(m.result);
19+ } else {
20+ worker.terminate();
21+ rej(new Error(m.message));
22+ }
23+ };
24+ worker.onerror = (e) => {
25+ worker.terminate();
26+ rej(new Error(e.message));
27+ };
28+ });
29+ worker.postMessage({ sys, exc, opt });
30+ return {
31+ promise,
32+ cancel: () => {
33+ worker.terminate();
34+ reject(new Error('cancelled'));
35+ },
36+ };
37+}
src/sim/solver.tsadded+246−0View file
@@ -0,0 +1,246 @@
1+import type { BeadSystem, Excitation, SimOptions, SimResult } from './types';
2+
3+// Upper bound on the highest angular frequency of the linearized system
4+// (Gershgorin on M^-1 K at rest). A spring under tension T has stiffness k along
5+// its axis and T/l across it, so each spring contributes 2 max(k, |T|/l) / m.
6+export function omegaMaxBound(sys: BeadSystem): number {
7+ const { n, si, sj, sk, sL0 } = sys;
8+ const rowSum = new Float64Array(n);
9+ const p = (node: number, c: number) =>
10+ node < n ? sys.rest[3 * node + c] : sys.anchors[node - n].pos[c];
11+ for (let s = 0; s < si.length; s++) {
12+ const a = si[s];
13+ const b = sj[s];
14+ let l2 = 0;
15+ for (let c = 0; c < 3; c++) l2 += (p(b, c) - p(a, c)) ** 2;
16+ const l = Math.sqrt(l2);
17+ const T = sk[s] * (l - sL0[s]);
18+ const kmax = Math.max(sk[s], Math.abs(T) / l);
19+ if (a < n) rowSum[a] += 2 * kmax;
20+ if (b < n) rowSum[b] += 2 * kmax;
21+ }
22+ let w2 = 0;
23+ for (let i = 0; i < n; i++) if (!sys.pinned[i]) w2 = Math.max(w2, rowSum[i] / sys.mass[i]);
24+ return Math.sqrt(w2);
25+}
26+
27+export type Progress = (fraction: number) => void;
28+
29+// Velocity Verlet (kick-drift-kick), explicit. Spring forces are exact
30+// (geometric nonlinearity kept); damping uses the half-step velocity in the
31+// kick, and the full-step velocity whenever an audio sample is recorded.
32+export function simulate(
33+ sys: BeadSystem,
34+ exc: Excitation,
35+ opt: SimOptions,
36+ progress?: Progress,
37+): SimResult {
38+ const t0 = performance.now();
39+ const { n, si, sj, sk, sL0, sc, mass, pinned, anchors } = sys;
40+ const nn = n + anchors.length;
41+ const ns = si.length;
42+
43+ const omegaMax = omegaMaxBound(sys);
44+ const dtMax = (opt.cfl * 2) / omegaMax;
45+ const stepsPerSample = Math.max(1, Math.ceil(1 / (opt.fs * dtMax)));
46+ const dt = 1 / (opt.fs * stepsPerSample);
47+ const nSamples = Math.round(opt.duration * opt.fs);
48+ const samplesPerFrame = Math.max(1, Math.round(opt.fs / opt.frameRate));
49+ const nFrames = Math.floor((nSamples - 1) / samplesPerFrame) + 1;
50+
51+ const X = new Float64Array(3 * nn);
52+ const V = new Float64Array(3 * nn);
53+ const F = new Float64Array(3 * n); // force on beads
54+ const A = new Float64Array(3 * n);
55+ for (let i = 0; i < n; i++) {
56+ for (let c = 0; c < 3; c++) {
57+ const k = 3 * i + c;
58+ X[k] = sys.rest[k] + (pinned[i] ? 0 : exc.u0[k]);
59+ V[k] = pinned[i] ? 0 : exc.v0[k];
60+ }
61+ }
62+ const slide: number[] = [];
63+ for (let a = 0; a < anchors.length; a++) {
64+ for (let c = 0; c < 3; c++) X[3 * (n + a) + c] = anchors[a].pos[c];
65+ if (anchors[a].slideBead >= 0) slide.push(a);
66+ }
67+ const syncAnchors = () => {
68+ for (const a of slide) {
69+ const b = anchors[a].slideBead;
70+ const k = 3 * (n + a);
71+ X[k + 1] = X[3 * b + 1];
72+ X[k + 2] = X[3 * b + 2];
73+ V[k + 1] = V[3 * b + 1];
74+ V[k + 2] = V[3 * b + 2];
75+ }
76+ };
77+ syncAnchors();
78+
79+ const force = exc.force;
80+ const fShape = force?.shape;
81+ const pulse = (t: number) =>
82+ force && t >= 0 && t <= force.tau
83+ ? force.F0 * 0.5 * (1 - Math.cos((2 * Math.PI * t) / force.tau))
84+ : 0;
85+
86+ // Fills F with total force on beads; returns damping power (W, >= 0)
87+ // and external power (W), both at velocity V.
88+ let pDamp = 0;
89+ let pExt = 0;
90+ const computeForces = (t: number) => {
91+ F.fill(0);
92+ pDamp = 0;
93+ for (let s = 0; s < ns; s++) {
94+ const a = 3 * si[s];
95+ const b = 3 * sj[s];
96+ const dx = X[b] - X[a];
97+ const dy = X[b + 1] - X[a + 1];
98+ const dz = X[b + 2] - X[a + 2];
99+ const l = Math.sqrt(dx * dx + dy * dy + dz * dz);
100+ const g = (sk[s] * (l - sL0[s])) / l;
101+ let fx = g * dx;
102+ let fy = g * dy;
103+ let fz = g * dz;
104+ const c = sc[s];
105+ if (c !== 0) {
106+ const rx = V[b] - V[a];
107+ const ry = V[b + 1] - V[a + 1];
108+ const rz = V[b + 2] - V[a + 2];
109+ fx += c * rx;
110+ fy += c * ry;
111+ fz += c * rz;
112+ pDamp += c * (rx * rx + ry * ry + rz * rz);
113+ }
114+ if (si[s] < n) {
115+ F[a] += fx;
116+ F[a + 1] += fy;
117+ F[a + 2] += fz;
118+ }
119+ if (sj[s] < n) {
120+ F[b] -= fx;
121+ F[b + 1] -= fy;
122+ F[b + 2] -= fz;
123+ }
124+ }
125+ const s0 = sys.sigma0;
126+ const p = fShape ? pulse(t) : 0;
127+ pExt = 0;
128+ for (let i = 0; i < n; i++) {
129+ const k = 3 * i;
130+ if (pinned[i]) {
131+ F[k] = F[k + 1] = F[k + 2] = 0;
132+ continue;
133+ }
134+ if (s0 !== 0) {
135+ const m2 = 2 * s0 * mass[i];
136+ for (let c = 0; c < 3; c++) {
137+ F[k + c] -= m2 * V[k + c];
138+ pDamp += m2 * V[k + c] * V[k + c];
139+ }
140+ }
141+ if (p !== 0) {
142+ for (let c = 0; c < 3; c++) {
143+ const fe = p * fShape![k + c];
144+ F[k + c] += fe;
145+ pExt += fe * V[k + c];
146+ }
147+ }
148+ }
149+ for (let i = 0; i < n; i++) {
150+ const im = 1 / mass[i];
151+ A[3 * i] = F[3 * i] * im;
152+ A[3 * i + 1] = F[3 * i + 1] * im;
153+ A[3 * i + 2] = F[3 * i + 2] * im;
154+ }
155+ };
156+
157+ const damped = sys.sigma0 !== 0 || sc.some((c) => c !== 0);
158+ let maxStretch = 0;
159+ const energyNow = () => {
160+ let e = 0;
161+ for (let i = 0; i < n; i++) {
162+ const k = 3 * i;
163+ e += 0.5 * mass[i] * (V[k] * V[k] + V[k + 1] * V[k + 1] + V[k + 2] * V[k + 2]);
164+ }
165+ for (let s = 0; s < ns; s++) {
166+ const a = 3 * si[s];
167+ const b = 3 * sj[s];
168+ const l = Math.hypot(X[b] - X[a], X[b + 1] - X[a + 1], X[b + 2] - X[a + 2]);
169+ const d = l - sL0[s];
170+ e += 0.5 * sk[s] * d * d;
171+ maxStretch = Math.max(maxStretch, Math.abs(d) / sL0[s]);
172+ }
173+ return e;
174+ };
175+
176+ const audio = new Float32Array(nSamples);
177+ const frames = new Float32Array(nFrames * 3 * n);
178+ const frameTimes = new Float64Array(nFrames);
179+ const energy = new Float64Array(nFrames);
180+ const balance = new Float64Array(nFrames);
181+ const w = opt.pickup;
182+
183+ // energy is reported relative to the equilibrium state (pre-tension energy subtracted)
184+ let Eeq = 0;
185+ for (let s = 0; s < ns; s++) {
186+ const pa = (node: number, c: number) => (node < n ? sys.rest[3 * node + c] : anchors[node - n].pos[c]);
187+ const l = Math.hypot(pa(sj[s], 0) - pa(si[s], 0), pa(sj[s], 1) - pa(si[s], 1), pa(sj[s], 2) - pa(si[s], 2));
188+ Eeq += 0.5 * sk[s] * (l - sL0[s]) ** 2;
189+ }
190+ const E0 = energyNow();
191+ let dissipated = 0;
192+ let workIn = 0;
193+ let t = 0;
194+ computeForces(0);
195+ let frame = 0;
196+ const progressEvery = Math.max(1, Math.floor(nSamples / 100));
197+
198+ for (let smp = 0; smp < nSamples; smp++) {
199+ // record at t = smp / fs; A holds forces at full-step velocity here
200+ let acc = 0;
201+ for (let k = 0; k < 3 * n; k++) acc += w[k] * A[k];
202+ audio[smp] = acc;
203+ if (smp % samplesPerFrame === 0 && frame < nFrames) {
204+ const off = frame * 3 * n;
205+ for (let k = 0; k < 3 * n; k++) frames[off + k] = X[k] - sys.rest[k];
206+ frameTimes[frame] = t;
207+ energy[frame] = energyNow() - Eeq;
208+ balance[frame] = energy[frame] + Eeq + dissipated - workIn - E0;
209+ frame++;
210+ }
211+ if (smp === nSamples - 1) break;
212+
213+ for (let st = 0; st < stepsPerSample; st++) {
214+ // kick (half), drift
215+ for (let k = 0; k < 3 * n; k++) {
216+ V[k] += 0.5 * dt * A[k];
217+ X[k] += dt * V[k];
218+ }
219+ syncAnchors();
220+ t += dt;
221+ // forces at new position with half-step velocity; powers use it too
222+ computeForces(t);
223+ dissipated += dt * pDamp;
224+ workIn += dt * pExt;
225+ for (let k = 0; k < 3 * n; k++) V[k] += 0.5 * dt * A[k];
226+ syncAnchors();
227+ }
228+ // at a sample time: recompute with the full-step velocity
229+ if (damped) computeForces(t);
230+ if (progress && smp % progressEvery === 0) progress(smp / nSamples);
231+ }
232+
233+ return {
234+ audio,
235+ fs: opt.fs,
236+ frames,
237+ frameTimes,
238+ energy,
239+ balance,
240+ dt,
241+ stepsPerSample,
242+ omegaMax,
243+ wallSeconds: (performance.now() - t0) / 1000,
244+ maxStretch,
245+ };
246+}
src/sim/tridiag.tsadded+41−0View file
@@ -0,0 +1,41 @@
1+// Eigenvalues of a symmetric tridiagonal matrix (diagonal d, off-diagonal e,
2+// e[i] couples i and i+1) by Sturm-sequence bisection. Zero off-diagonals
3+// split it into independent blocks, which the count handles.
4+
5+function countBelow(d: Float64Array, e: Float64Array, x: number): number {
6+ let count = 0;
7+ let q = 1;
8+ for (let i = 0; i < d.length; i++) {
9+ const e2 = i > 0 ? e[i - 1] * e[i - 1] : 0;
10+ q = d[i] - x - (i > 0 ? e2 / q : 0);
11+ if (q === 0) q = -1e-300;
12+ if (q < 0) count++;
13+ }
14+ return count;
15+}
16+
17+export function tridiagEigenvalues(d: Float64Array, e: Float64Array, count = d.length): number[] {
18+ const n = d.length;
19+ if (n === 0) return [];
20+ let lo = Infinity;
21+ let hi = -Infinity;
22+ for (let i = 0; i < n; i++) {
23+ const r = (i > 0 ? Math.abs(e[i - 1]) : 0) + (i < n - 1 ? Math.abs(e[i]) : 0);
24+ lo = Math.min(lo, d[i] - r);
25+ hi = Math.max(hi, d[i] + r);
26+ }
27+ const tol = 1e-13 * Math.max(Math.abs(lo), Math.abs(hi), 1e-300);
28+ const out: number[] = [];
29+ for (let k = 0; k < Math.min(count, n); k++) {
30+ let a = lo;
31+ let b = hi;
32+ while (b - a > tol) {
33+ const mid = 0.5 * (a + b);
34+ if (countBelow(d, e, mid) > k) b = mid;
35+ else a = mid;
36+ if (mid === a && mid === b) break;
37+ }
38+ out.push(0.5 * (a + b));
39+ }
40+ return out;
41+}
src/sim/types.tsadded+53−0View file
@@ -0,0 +1,53 @@
1+// Generic bead-spring system in 3D. Instruments build one of these; the solver
2+// integrates it. SI units throughout.
3+
4+export interface Anchor {
5+ pos: [number, number, number];
6+ // -1: fixed point. Otherwise a massless ring on a rod: x fixed, y and z
7+ // follow this bead, so the spring to it stays parallel to x (no transverse force).
8+ slideBead: number;
9+}
10+
11+export interface BeadSystem {
12+ n: number; // number of beads
13+ rest: Float64Array; // 3n equilibrium positions
14+ mass: Float64Array; // n
15+ pinned: Uint8Array; // n, 1 = held fixed at rest position
16+ anchors: Anchor[]; // node index n + a
17+ // springs: endpoints are node indices (bead < n, anchor >= n)
18+ si: Int32Array;
19+ sj: Int32Array;
20+ sk: Float64Array; // stiffness (N/m)
21+ sL0: Float64Array; // rest length (m)
22+ sc: Float64Array; // dashpot on relative velocity, all 3 components (N s/m)
23+ sigma0: number; // mass-proportional damping: force -2 sigma0 m v
24+}
25+
26+export interface Excitation {
27+ u0: Float64Array; // 3n initial displacement from rest
28+ v0: Float64Array; // 3n initial velocity
29+ // External force F(t) = shape * F0 * 0.5 (1 - cos(2 pi t / tau)), 0 <= t <= tau
30+ force?: { shape: Float64Array; F0: number; tau: number };
31+}
32+
33+export interface SimOptions {
34+ duration: number; // s
35+ fs: number; // audio sample rate (Hz)
36+ cfl: number; // fraction of the leapfrog stability limit 2/omega_max
37+ frameRate: number; // displacement frames per second
38+ pickup: Float64Array; // 3n weights; audio = sum w . acceleration
39+}
40+
41+export interface SimResult {
42+ audio: Float32Array; // pickup acceleration (m/s^2) at fs
43+ fs: number;
44+ frames: Float32Array; // nFrames x 3n displacement from rest
45+ frameTimes: Float64Array;
46+ energy: Float64Array; // mechanical energy above equilibrium at frame times (J)
47+ balance: Float64Array; // E + dissipated - work_in - E0 at frame times (J)
48+ dt: number;
49+ stepsPerSample: number;
50+ omegaMax: number;
51+ wallSeconds: number;
52+ maxStretch: number; // largest |l - L0| / L0 seen, at frame times
53+}
src/sim/worker.tsadded+20−0View file
@@ -0,0 +1,20 @@
1+import { simulate } from './solver';
2+import type { BeadSystem, Excitation, SimOptions } from './types';
3+
4+export interface WorkerRequest {
5+ sys: BeadSystem;
6+ exc: Excitation;
7+ opt: SimOptions;
8+}
9+
10+self.onmessage = (ev: MessageEvent<WorkerRequest>) => {
11+ const { sys, exc, opt } = ev.data;
12+ try {
13+ const result = simulate(sys, exc, opt, (f) => self.postMessage({ type: 'progress', fraction: f }));
14+ self.postMessage({ type: 'done', result }, {
15+ transfer: [result.audio.buffer, result.frames.buffer, result.frameTimes.buffer, result.energy.buffer, result.balance.buffer],
16+ });
17+ } catch (e) {
18+ self.postMessage({ type: 'error', message: String(e) });
19+ }
20+};
src/style.cssadded+148−0View file
@@ -0,0 +1,148 @@
1+:root {
2+ color-scheme: light;
3+ --page: #f4f3f0;
4+ --surface-1: #fcfcfb;
5+ --border: #e2e0da;
6+ --text-primary: #0b0b0b;
7+ --text-secondary: #52514e;
8+ --text-muted: #77756f;
9+ --grid: #ecebe7;
10+ --axis: #b9b7b0;
11+ --accent: #2a78d6;
12+ --accent-ink: #ffffff;
13+ --warn-bg: #fdf3dc;
14+ --warn-ink: #6b4a00;
15+ --series-1: #2a78d6;
16+ --series-2: #eb6834;
17+ --ramp: #fcfcfb, #cde2fb, #86b6ef, #3987e5, #1c5cab, #0d366b;
18+ --diverging: #1c5cab, #86b6ef, #f0efec, #f0a3a2, #b42d2c;
19+}
20+@media (prefers-color-scheme: dark) {
21+ :root:not([data-theme='light']) {
22+ color-scheme: dark;
23+ --page: #121211;
24+ --surface-1: #1a1a19;
25+ --border: #2f2f2c;
26+ --text-primary: #ffffff;
27+ --text-secondary: #c3c2b7;
28+ --text-muted: #94938a;
29+ --grid: #262624;
30+ --axis: #4a4a46;
31+ --accent: #3987e5;
32+ --warn-bg: #3a2e12;
33+ --warn-ink: #f3d18a;
34+ --series-1: #3987e5;
35+ --series-2: #d95926;
36+ --ramp: #1a1a19, #104281, #1c5cab, #3987e5, #86b6ef, #cde2fb;
37+ --diverging: #6da7ec, #1c5cab, #383835, #a83434, #e66767;
38+ }
39+}
40+:root[data-theme='dark'] {
41+ color-scheme: dark;
42+ --page: #121211;
43+ --surface-1: #1a1a19;
44+ --border: #2f2f2c;
45+ --text-primary: #ffffff;
46+ --text-secondary: #c3c2b7;
47+ --text-muted: #94938a;
48+ --grid: #262624;
49+ --axis: #4a4a46;
50+ --accent: #3987e5;
51+ --warn-bg: #3a2e12;
52+ --warn-ink: #f3d18a;
53+ --series-1: #3987e5;
54+ --series-2: #d95926;
55+ --ramp: #1a1a19, #104281, #1c5cab, #3987e5, #86b6ef, #cde2fb;
56+ --diverging: #6da7ec, #1c5cab, #383835, #a83434, #e66767;
57+}
58+
59+* { box-sizing: border-box; }
60+body {
61+ margin: 0;
62+ background: var(--page);
63+ color: var(--text-primary);
64+ font: 14px/1.45 system-ui, -apple-system, 'Segoe UI', sans-serif;
65+}
66+h1 { font-size: 22px; margin: 4px 0 8px; }
67+h2 { font-size: 15px; margin: 0 0 10px; }
68+a { color: var(--accent); }
69+.sub { color: var(--text-secondary); margin: 0 0 12px; }
70+.note { color: var(--text-muted); font-size: 12px; margin: 6px 0 0; }
71+.mono { font-variant-numeric: tabular-nums; font-family: ui-monospace, monospace; font-size: 12px; }
72+
73+.hero { max-width: 1100px; margin: 32px auto 8px; padding: 0 16px; }
74+.gallery {
75+ max-width: 1100px; margin: 0 auto 32px; padding: 0 16px;
76+ display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
77+}
78+.tile {
79+ display: block; background: var(--surface-1); border: 1px solid var(--border); border-radius: 10px;
80+ padding: 16px; color: inherit; text-decoration: none; position: relative;
81+}
82+.tile:not(.soon):hover { border-color: var(--accent); }
83+.tile p { color: var(--text-secondary); margin: 0; }
84+.tile.soon { opacity: 0.6; cursor: default; }
85+.badge { display: inline-block; margin-top: 10px; font-size: 11px; color: var(--text-muted); border: 1px solid var(--border); border-radius: 999px; padding: 1px 8px; }
86+
87+.instrument { display: grid; grid-template-columns: 340px minmax(0, 1fr); gap: 16px; padding: 16px; max-width: 1500px; margin: 0 auto; }
88+@media (max-width: 860px) { .instrument { grid-template-columns: 1fr; padding: 16px; } }
89+.panel {
90+ background: var(--surface-1); border: 1px solid var(--border); border-radius: 10px; padding: 14px;
91+ align-self: start; position: sticky; top: 16px; max-height: calc(100vh - 32px); overflow: auto;
92+}
93+@media (max-width: 860px) { .panel { position: static; max-height: none; } }
94+.back { font-size: 13px; text-decoration: none; }
95+fieldset { border: none; border-top: 1px solid var(--border); margin: 10px 0 0; padding: 8px 0 0; }
96+legend { font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-secondary); padding: 0 6px 0 0; }
97+.ctl { display: grid; grid-template-columns: 112px minmax(0, 1fr) auto; align-items: center; gap: 8px; margin: 5px 0; }
98+.ctl[hidden] { display: none; }
99+.ctl-name { color: var(--text-secondary); font-size: 13px; }
100+.ctl-unit { color: var(--text-muted); font-size: 12px; min-width: 34px; }
101+.ctl.preset { grid-template-columns: 60px minmax(0, 1fr); }
102+input, select {
103+ font: inherit; font-size: 13px; color: var(--text-primary); background: var(--page);
104+ border: 1px solid var(--border); border-radius: 6px; padding: 4px 6px; min-width: 0; width: 100%;
105+}
106+.inline select { width: auto; }
107+input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
108+.btn, .btn-small {
109+ font: inherit; border-radius: 6px; cursor: pointer; text-decoration: none; display: inline-block;
110+}
111+.btn { background: var(--accent); color: var(--accent-ink); border: none; padding: 8px 16px; font-weight: 600; }
112+.btn:disabled { opacity: 0.5; cursor: default; }
113+.btn-small { background: transparent; color: var(--text-primary); border: 1px solid var(--border); padding: 4px 10px; font-size: 13px; }
114+.run-row { display: flex; gap: 8px; align-items: center; margin-top: 14px; }
115+progress { width: 100%; margin-top: 8px; accent-color: var(--accent); }
116+
117+.results { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
118+.card { background: var(--surface-1); border: 1px solid var(--border); border-radius: 10px; padding: 14px; min-width: 0; }
119+.card[hidden] { display: none; }
120+.card-head { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: 8px; }
121+.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px 16px; }
122+.stats div { display: flex; flex-direction: column; }
123+.stats span { color: var(--text-muted); font-size: 12px; }
124+.stats b { font-weight: 600; font-variant-numeric: tabular-nums; }
125+.warnings { margin: 10px 0 0; padding: 0; list-style: none; }
126+.warnings li { background: var(--warn-bg); color: var(--warn-ink); border-radius: 6px; padding: 6px 10px; margin-top: 6px; font-size: 13px; }
127+.warnings li::before { content: '⚠ '; }
128+.audio-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-top: 12px; }
129+audio { max-width: 100%; }
130+
131+.canvas-wrap { position: relative; height: 220px; }
132+.canvas-wrap.tall { height: 320px; }
133+.canvas-wrap.short { height: 170px; }
134+.canvas-wrap canvas { width: 100%; height: 100%; display: block; }
135+.tooltip {
136+ position: absolute; pointer-events: none; white-space: pre; font-size: 12px; line-height: 1.4;
137+ background: var(--surface-1); color: var(--text-primary); border: 1px solid var(--border);
138+ border-radius: 6px; padding: 4px 8px; box-shadow: 0 2px 6px rgb(0 0 0 / 0.15); font-variant-numeric: tabular-nums;
139+}
140+.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
141+.seg button { font: inherit; font-size: 12px; background: transparent; color: var(--text-secondary); border: none; padding: 3px 10px; cursor: pointer; }
142+.seg button.on { background: var(--accent); color: var(--accent-ink); }
143+.legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; color: var(--text-secondary); }
144+.legend .sw { display: inline-block; width: 12px; height: 3px; border-radius: 2px; vertical-align: middle; margin-right: 5px; }
145+.legend .dash { display: inline-block; width: 0; height: 12px; border-left: 2px dashed; vertical-align: middle; margin-right: 5px; }
146+.viewer-bar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin: 6px 0; }
147+.viewer-bar input[type='range'] { flex: 1; min-width: 140px; padding: 0; border: none; background: none; accent-color: var(--accent); }
148+.inline { font-size: 12px; color: var(--text-secondary); display: inline-flex; gap: 6px; align-items: center; }
src/ui/chainview.tsadded+255−0View file
@@ -0,0 +1,255 @@
1+import { css, drawAxes, fmtNum, PAD, setupCanvas } from './plot';
2+import { rampLUT } from './spectrogram';
3+
4+// Displacement viewer for a chain of beads along x: side (y) and top (z)
5+// views at one frame, plus a space-time image of one component.
6+
7+export interface ChainGeometry {
8+ n: number;
9+ restX: Float64Array; // bead rest x
10+ leftWall: number | null; // x of left anchor, null if free
11+ rightWall: number | null;
12+ slideLeft: boolean;
13+ slideRight: boolean;
14+ pinned: Uint8Array;
15+}
16+
17+export interface ChainFrames {
18+ frames: Float32Array; // nFrames x 3n
19+ times: Float64Array;
20+}
21+
22+export class ChainView {
23+ private g: ChainGeometry | null = null;
24+ private f: ChainFrames | null = null;
25+ private frame = 0;
26+ private playing = false;
27+ private rate = 1 / 100;
28+ private comp = 1;
29+ private last = 0;
30+ private ptime = 0;
31+ private maxAbs = [0, 0, 0];
32+ private exaggeration = 1;
33+
34+ private side: HTMLCanvasElement;
35+ private top: HTMLCanvasElement;
36+ private st: HTMLCanvasElement;
37+ private slider: HTMLInputElement;
38+ private playBtn: HTMLButtonElement;
39+ private label: HTMLSpanElement;
40+ private note: HTMLParagraphElement;
41+
42+ constructor(root: HTMLElement) {
43+ root.innerHTML = `
44+ <div class="viewer-bar">
45+ <button class="btn-small" data-r="play">Play</button>
46+ <input type="range" min="0" max="0" value="0" data-r="slider" aria-label="frame">
47+ <span class="mono" data-r="label"></span>
48+ <label class="inline">speed <select data-r="rate">
49+ <option value="0.001">1/1000 ×</option><option value="0.01" selected>1/100 ×</option>
50+ <option value="0.05">1/20 ×</option><option value="0.2">1/5 ×</option><option value="1">1 ×</option>
51+ </select></label>
52+ </div>
53+ <div class="canvas-wrap short"><canvas data-r="side"></canvas></div>
54+ <div class="canvas-wrap short"><canvas data-r="top"></canvas></div>
55+ <p class="note" data-r="note"></p>
56+ <div class="viewer-bar"><span class="sub">Space-time</span>
57+ <label class="inline">component <select data-r="comp">
58+ <option value="1" selected>y (transverse)</option><option value="2">z (transverse)</option><option value="0">x (longitudinal)</option>
59+ </select></label></div>
60+ <div class="canvas-wrap"><canvas data-r="st"></canvas></div>`;
61+ const q = <T extends HTMLElement>(r: string) => root.querySelector(`[data-r="${r}"]`) as T;
62+ this.side = q('side');
63+ this.top = q('top');
64+ this.st = q('st');
65+ this.slider = q('slider');
66+ this.playBtn = q('play');
67+ this.label = q('label');
68+ this.note = q('note');
69+ this.slider.addEventListener('input', () => this.seek(Number(this.slider.value)));
70+ this.playBtn.addEventListener('click', () => this.toggle());
71+ q<HTMLSelectElement>('rate').addEventListener('change', (e) => (this.rate = Number((e.target as HTMLSelectElement).value)));
72+ q<HTMLSelectElement>('comp').addEventListener('change', (e) => {
73+ this.comp = Number((e.target as HTMLSelectElement).value);
74+ this.drawSpaceTime();
75+ });
76+ this.st.addEventListener('click', (e) => {
77+ if (!this.f) return;
78+ const w = this.st.clientWidth;
79+ const fx = (e.offsetX - PAD.l) / (w - PAD.l - PAD.r);
80+ if (fx >= 0 && fx <= 1) this.seek(Math.round(fx * (this.f.times.length - 1)));
81+ });
82+ const ro = new ResizeObserver(() => this.drawAll());
83+ for (const c of [this.side, this.top, this.st]) ro.observe(c);
84+ }
85+
86+ set(g: ChainGeometry, f: ChainFrames) {
87+ this.g = g;
88+ this.f = f;
89+ this.maxAbs = [0, 0, 0];
90+ for (let i = 0; i < f.frames.length; i++) {
91+ const c = i % 3;
92+ this.maxAbs[c] = Math.max(this.maxAbs[c], Math.abs(f.frames[i]));
93+ }
94+ this.slider.max = String(f.times.length - 1);
95+ this.frame = 0;
96+ this.ptime = 0;
97+ this.slider.value = '0';
98+ this.drawAll();
99+ }
100+
101+ private seek(k: number) {
102+ this.show(k);
103+ this.ptime = this.f?.times[this.frame] ?? 0;
104+ }
105+
106+ private show(k: number) {
107+ this.frame = Math.max(0, Math.min(k, (this.f?.times.length ?? 1) - 1));
108+ this.slider.value = String(this.frame);
109+ this.drawFrame();
110+ this.drawSpaceTime();
111+ }
112+
113+ private toggle() {
114+ if (!this.f) return;
115+ this.playing = !this.playing;
116+ this.playBtn.textContent = this.playing ? 'Pause' : 'Play';
117+ if (this.playing) {
118+ if (this.frame >= this.f.times.length - 1) this.seek(0);
119+ this.last = performance.now();
120+ requestAnimationFrame(this.tick);
121+ }
122+ }
123+
124+ private tick = (now: number) => {
125+ if (!this.playing || !this.f) return;
126+ const times = this.f.times;
127+ this.ptime += ((now - this.last) / 1000) * this.rate;
128+ this.last = now;
129+ let k = this.frame;
130+ while (k < times.length - 1 && times[k + 1] <= this.ptime) k++;
131+ this.show(k);
132+ if (k >= times.length - 1) this.toggle();
133+ else requestAnimationFrame(this.tick);
134+ };
135+
136+ private drawAll() {
137+ this.drawFrame();
138+ this.drawSpaceTime();
139+ }
140+
141+ private drawFrame() {
142+ const g = this.g;
143+ const f = this.f;
144+ if (!g || !f) return;
145+ const xs = [g.leftWall ?? g.restX[0], g.rightWall ?? g.restX[g.n - 1]];
146+ const L = xs[1] - xs[0] || 1;
147+ const xr: [number, number] = [xs[0] - 0.03 * L, xs[1] + 0.03 * L];
148+ const panelH = this.side.clientHeight - PAD.t - PAD.b;
149+ const pxPerM = (this.side.clientWidth - PAD.l - PAD.r) / (xr[1] - xr[0]);
150+ const tmax = Math.max(this.maxAbs[1], this.maxAbs[2], 1e-30);
151+ this.exaggeration = Math.max(1, (0.42 * panelH) / (tmax * pxPerM));
152+ // displacement range shown; at true scale a large motion may need more than the 1:1 aspect allows
153+ const half = Math.max((0.5 * panelH) / pxPerM / this.exaggeration, 1.15 * tmax);
154+ this.drawView(this.side, 1, xr, half);
155+ this.drawView(this.top, 2, xr, half);
156+ const t = f.times[this.frame];
157+ this.label.textContent = `t = ${(t * 1000).toFixed(2)} ms`;
158+ this.note.textContent =
159+ `Displacements (all components) drawn ×${fmtNum(this.exaggeration)}. Max |y| ${fmtNum(this.maxAbs[1] * 1000)} mm, ` +
160+ `max |z| ${fmtNum(this.maxAbs[2] * 1000)} mm, max |x| ${fmtNum(this.maxAbs[0] * 1000)} mm over the run.`;
161+ }
162+
163+ private drawView(canvas: HTMLCanvasElement, comp: 1 | 2, xr: [number, number], half: number) {
164+ const g = this.g!;
165+ const f = this.f!;
166+ const { ctx, w, h } = setupCanvas(canvas);
167+ ctx.clearRect(0, 0, w, h);
168+ const { X, Y } = drawAxes(ctx, w, h, xr, [-half * 1000, half * 1000], {
169+ xLabel: 'x (m)',
170+ yLabel: comp === 1 ? 'y (mm)' : 'z (mm)',
171+ });
172+ const E = this.exaggeration;
173+ const off = this.frame * 3 * g.n;
174+ const pts: [number, number][] = [];
175+ const bead = (i: number): [number, number] => [
176+ X(g.restX[i] + E * f.frames[off + 3 * i]),
177+ Y(f.frames[off + 3 * i + comp] * 1000),
178+ ];
179+ if (g.leftWall !== null) pts.push([X(g.leftWall), g.slideLeft ? bead(0)[1] : Y(0)]);
180+ for (let i = 0; i < g.n; i++) pts.push(bead(i));
181+ if (g.rightWall !== null) pts.push([X(g.rightWall), g.slideRight ? bead(g.n - 1)[1] : Y(0)]);
182+ // walls
183+ ctx.strokeStyle = css('--text-muted');
184+ ctx.lineWidth = 2;
185+ for (const [wx, slide] of [[g.leftWall, g.slideLeft], [g.rightWall, g.slideRight]] as const) {
186+ if (wx === null) continue;
187+ ctx.setLineDash(slide ? [4, 3] : []);
188+ ctx.beginPath();
189+ ctx.moveTo(X(wx), PAD.t);
190+ ctx.lineTo(X(wx), h - PAD.b);
191+ ctx.stroke();
192+ }
193+ ctx.setLineDash([]);
194+ ctx.strokeStyle = css('--series-1');
195+ ctx.lineWidth = 1.5;
196+ ctx.beginPath();
197+ pts.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
198+ ctx.stroke();
199+ const r = g.n > 80 ? 2 : g.n > 30 ? 3 : 4.5;
200+ const surface = css('--surface-1');
201+ for (let i = 0; i < g.n; i++) {
202+ const [x, y] = bead(i);
203+ ctx.beginPath();
204+ ctx.arc(x, y, g.pinned[i] ? r + 1.5 : r, 0, 2 * Math.PI);
205+ ctx.fillStyle = g.pinned[i] ? css('--text-primary') : css('--series-1');
206+ ctx.fill();
207+ ctx.lineWidth = 1.5;
208+ ctx.strokeStyle = surface;
209+ ctx.stroke();
210+ }
211+ }
212+
213+ private drawSpaceTime() {
214+ const g = this.g;
215+ const f = this.f;
216+ if (!g || !f) return;
217+ const { ctx, w, h } = setupCanvas(this.st);
218+ ctx.clearRect(0, 0, w, h);
219+ const nf = f.times.length;
220+ const c = this.comp;
221+ const m = Math.max(this.maxAbs[c], 1e-30);
222+ const lut = rampLUT('--diverging');
223+ const img = new ImageData(nf, g.n);
224+ for (let k = 0; k < nf; k++) {
225+ for (let i = 0; i < g.n; i++) {
226+ const v = f.frames[k * 3 * g.n + 3 * i + c] / m;
227+ const li = Math.round((0.5 + 0.5 * Math.max(-1, Math.min(1, v))) * 255) * 3;
228+ const p = 4 * ((g.n - 1 - i) * nf + k);
229+ img.data[p] = lut[li];
230+ img.data[p + 1] = lut[li + 1];
231+ img.data[p + 2] = lut[li + 2];
232+ img.data[p + 3] = 255;
233+ }
234+ }
235+ const off = document.createElement('canvas');
236+ off.width = nf;
237+ off.height = g.n;
238+ off.getContext('2d')!.putImageData(img, 0, 0);
239+ ctx.imageSmoothingEnabled = false;
240+ ctx.drawImage(off, PAD.l, PAD.t, w - PAD.l - PAD.r, h - PAD.t - PAD.b);
241+ const t1 = f.times[nf - 1];
242+ const { X } = drawAxes(ctx, w, h, [0, t1], [0.5, g.n + 0.5], {
243+ xLabel: `time (s); color: ${['x', 'y', 'z'][c]} displacement, ±${fmtNum(m * 1000)} mm`,
244+ yLabel: 'bead',
245+ grid: false,
246+ });
247+ ctx.strokeStyle = css('--text-primary');
248+ ctx.lineWidth = 1;
249+ const cx = Math.round(X(f.times[this.frame])) + 0.5;
250+ ctx.beginPath();
251+ ctx.moveTo(cx, PAD.t);
252+ ctx.lineTo(cx, h - PAD.b);
253+ ctx.stroke();
254+ }
255+}
src/ui/controls.tsadded+125−0View file
@@ -0,0 +1,125 @@
1+// A form generated from a parameter spec, reusable across instruments.
2+
3+export interface ParamSpec<P> {
4+ key: keyof P & string;
5+ label: string;
6+ kind: 'number' | 'int' | 'select' | 'text';
7+ unit?: string;
8+ scale?: number; // displayed value = stored value * scale
9+ options?: [string, string][]; // [value, label]
10+ help?: string;
11+ min?: number;
12+ show?: (p: P) => boolean;
13+}
14+
15+export interface Section<P> {
16+ title: string;
17+ params: ParamSpec<P>[];
18+}
19+
20+export interface Controls<P> {
21+ get(): P;
22+ set(p: P): void;
23+}
24+
25+export function renderControls<P extends object>(
26+ root: HTMLElement,
27+ sections: Section<P>[],
28+ initial: P,
29+ onChange: (p: P) => void,
30+): Controls<P> {
31+ let current = { ...initial };
32+ const inputs = new Map<string, { spec: ParamSpec<P>; el: HTMLInputElement | HTMLSelectElement; row: HTMLElement }>();
33+ root.replaceChildren();
34+ for (const sec of sections) {
35+ const fs = document.createElement('fieldset');
36+ const lg = document.createElement('legend');
37+ lg.textContent = sec.title;
38+ fs.append(lg);
39+ for (const spec of sec.params) {
40+ const row = document.createElement('label');
41+ row.className = 'ctl';
42+ const name = document.createElement('span');
43+ name.className = 'ctl-name';
44+ name.textContent = spec.label;
45+ if (spec.help) row.title = spec.help;
46+ let el: HTMLInputElement | HTMLSelectElement;
47+ if (spec.kind === 'select') {
48+ el = document.createElement('select');
49+ for (const [v, l] of spec.options ?? []) {
50+ const o = document.createElement('option');
51+ o.value = v;
52+ o.textContent = l;
53+ el.append(o);
54+ }
55+ } else {
56+ el = document.createElement('input');
57+ if (spec.kind === 'text') el.type = 'text';
58+ else {
59+ el.type = 'number';
60+ el.step = spec.kind === 'int' ? '1' : 'any';
61+ if (spec.min !== undefined) el.min = String(spec.min);
62+ }
63+ }
64+ el.addEventListener('change', () => {
65+ current = read();
66+ refreshVisibility();
67+ onChange(current);
68+ });
69+ row.append(name, el);
70+ if (spec.unit) {
71+ const u = document.createElement('span');
72+ u.className = 'ctl-unit';
73+ u.textContent = spec.unit;
74+ row.append(u);
75+ }
76+ fs.append(row);
77+ inputs.set(spec.key, { spec, el, row });
78+ }
79+ root.append(fs);
80+ }
81+
82+ const fmt = (v: number) => {
83+ if (v === 0) return '0';
84+ const a = Math.abs(v);
85+ return a >= 1e5 || a < 1e-3 ? v.toExponential() : String(Number(v.toPrecision(10)));
86+ };
87+
88+ function write(p: P) {
89+ for (const { spec, el } of inputs.values()) {
90+ const v = (p as Record<string, unknown>)[spec.key];
91+ el.value = typeof v === 'number' ? fmt(v * (spec.scale ?? 1)) : String(v);
92+ }
93+ }
94+
95+ function read(): P {
96+ const p = { ...current } as Record<string, unknown>;
97+ for (const { spec, el } of inputs.values()) {
98+ if (spec.kind === 'select' || spec.kind === 'text') {
99+ const v = el.value;
100+ p[spec.key] = spec.kind === 'select' && typeof p[spec.key] === 'number' ? Number(v) : v;
101+ } else {
102+ let v = Number(el.value) / (spec.scale ?? 1);
103+ if (spec.kind === 'int') v = Math.round(v);
104+ if (Number.isFinite(v)) p[spec.key] = v;
105+ }
106+ }
107+ return p as P;
108+ }
109+
110+ function refreshVisibility() {
111+ for (const { spec, row } of inputs.values()) row.hidden = spec.show ? !spec.show(current) : false;
112+ }
113+
114+ write(current);
115+ refreshVisibility();
116+ return {
117+ get: () => (current = read()),
118+ set(p: P) {
119+ current = { ...p };
120+ write(current);
121+ refreshVisibility();
122+ onChange(current);
123+ },
124+ };
125+}
src/ui/fft.tsadded+79−0View file
@@ -0,0 +1,79 @@
1+// In-place radix-2 complex FFT (length must be a power of two).
2+export function fft(re: Float64Array, im: Float64Array): void {
3+ const n = re.length;
4+ for (let i = 1, j = 0; i < n; i++) {
5+ let bit = n >> 1;
6+ for (; j & bit; bit >>= 1) j ^= bit;
7+ j ^= bit;
8+ if (i < j) {
9+ [re[i], re[j]] = [re[j], re[i]];
10+ [im[i], im[j]] = [im[j], im[i]];
11+ }
12+ }
13+ for (let len = 2; len <= n; len <<= 1) {
14+ const ang = (-2 * Math.PI) / len;
15+ const wr = Math.cos(ang);
16+ const wi = Math.sin(ang);
17+ for (let i = 0; i < n; i += len) {
18+ let cr = 1;
19+ let ci = 0;
20+ for (let k = 0; k < len / 2; k++) {
21+ const a = i + k;
22+ const b = a + len / 2;
23+ const tr = re[b] * cr - im[b] * ci;
24+ const ti = re[b] * ci + im[b] * cr;
25+ re[b] = re[a] - tr;
26+ im[b] = im[a] - ti;
27+ re[a] += tr;
28+ im[a] += ti;
29+ const nr = cr * wr - ci * wi;
30+ ci = cr * wi + ci * wr;
31+ cr = nr;
32+ }
33+ }
34+ }
35+}
36+
37+export interface Stft {
38+ mags: Float32Array[]; // dB per frame, nfft/2 + 1 bins
39+ hop: number;
40+ nfft: number;
41+ fs: number;
42+}
43+
44+export function stft(x: Float32Array, fs: number, nfft = 2048, hop = 256): Stft {
45+ const win = new Float64Array(nfft);
46+ for (let i = 0; i < nfft; i++) win[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / nfft);
47+ const mags: Float32Array[] = [];
48+ const re = new Float64Array(nfft);
49+ const im = new Float64Array(nfft);
50+ for (let start = -nfft / 2; start < x.length - nfft / 2; start += hop) {
51+ for (let i = 0; i < nfft; i++) {
52+ const j = start + i;
53+ re[i] = j >= 0 && j < x.length ? x[j] * win[i] : 0;
54+ im[i] = 0;
55+ }
56+ fft(re, im);
57+ const m = new Float32Array(nfft / 2 + 1);
58+ for (let k = 0; k <= nfft / 2; k++) m[k] = 10 * Math.log10(re[k] * re[k] + im[k] * im[k] + 1e-30);
59+ mags.push(m);
60+ }
61+ return { mags, hop, nfft, fs };
62+}
63+
64+// Power spectrum of the whole signal (Hann window, zero-padded), in dB.
65+export function spectrum(x: Float32Array, fs: number): { freq: Float64Array; db: Float64Array } {
66+ let n = 1;
67+ while (n < x.length) n <<= 1;
68+ const re = new Float64Array(n);
69+ const im = new Float64Array(n);
70+ for (let i = 0; i < x.length; i++) re[i] = x[i] * (0.5 - 0.5 * Math.cos((2 * Math.PI * i) / x.length));
71+ fft(re, im);
72+ const freq = new Float64Array(n / 2 + 1);
73+ const db = new Float64Array(n / 2 + 1);
74+ for (let k = 0; k <= n / 2; k++) {
75+ freq[k] = (k * fs) / n;
76+ db[k] = 10 * Math.log10(re[k] * re[k] + im[k] * im[k] + 1e-30);
77+ }
78+ return { freq, db };
79+}
src/ui/plot.tsadded+254−0View file
@@ -0,0 +1,254 @@
1+// Small canvas plotting helpers: one axis per chart, recessive grid,
2+// min/max decimation for long signals, crosshair + tooltip on hover.
3+
4+export const css = (name: string) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
5+
6+export interface Series {
7+ x: ArrayLike<number>; // increasing
8+ y: ArrayLike<number>;
9+ color: string;
10+ label: string;
11+}
12+
13+export interface Marker {
14+ x: number;
15+ color: string;
16+ label: string;
17+}
18+
19+export interface PlotOptions {
20+ xLabel: string;
21+ yLabel: string;
22+ xRange?: [number, number];
23+ yRange?: [number, number];
24+ markers?: Marker[];
25+ fmtX?: (v: number) => string;
26+ fmtY?: (v: number) => string;
27+}
28+
29+export const PAD = { l: 64, r: 12, t: 10, b: 34 };
30+
31+export function setupCanvas(canvas: HTMLCanvasElement): { ctx: CanvasRenderingContext2D; w: number; h: number } {
32+ const dpr = window.devicePixelRatio || 1;
33+ const w = canvas.clientWidth;
34+ const h = canvas.clientHeight;
35+ canvas.width = Math.round(w * dpr);
36+ canvas.height = Math.round(h * dpr);
37+ const ctx = canvas.getContext('2d')!;
38+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
39+ return { ctx, w, h };
40+}
41+
42+export function niceTicks(lo: number, hi: number, count = 5): number[] {
43+ const span = hi - lo;
44+ if (!(span > 0)) return [lo];
45+ const raw = span / count;
46+ const mag = 10 ** Math.floor(Math.log10(raw));
47+ const step = [1, 2, 5, 10].map((m) => m * mag).find((s) => s >= raw)!;
48+ const out: number[] = [];
49+ for (let v = Math.ceil(lo / step) * step; v <= hi + 1e-9 * step; v += step) out.push(Math.abs(v) < 1e-12 * step ? 0 : v);
50+ return out;
51+}
52+
53+export const fmtNum = (v: number) => {
54+ const a = Math.abs(v);
55+ if (a === 0) return '0';
56+ if (a >= 1e4 || a < 1e-2) return v.toExponential(1);
57+ return String(Number(v.toPrecision(4)));
58+};
59+
60+export function drawAxes(
61+ ctx: CanvasRenderingContext2D,
62+ w: number,
63+ h: number,
64+ xr: [number, number],
65+ yr: [number, number],
66+ o: Pick<PlotOptions, 'xLabel' | 'yLabel' | 'fmtX' | 'fmtY'> & { grid?: boolean },
67+) {
68+ const fx = o.fmtX ?? fmtNum;
69+ const fy = o.fmtY ?? fmtNum;
70+ const X = (v: number) => PAD.l + ((v - xr[0]) / (xr[1] - xr[0])) * (w - PAD.l - PAD.r);
71+ const Y = (v: number) => h - PAD.b - ((v - yr[0]) / (yr[1] - yr[0])) * (h - PAD.t - PAD.b);
72+ ctx.font = '11px system-ui, sans-serif';
73+ ctx.lineWidth = 1;
74+ ctx.strokeStyle = css('--grid');
75+ ctx.fillStyle = css('--text-muted');
76+ ctx.textAlign = 'right';
77+ ctx.textBaseline = 'middle';
78+ for (const t of niceTicks(yr[0], yr[1], 4)) {
79+ const y = Math.round(Y(t)) + 0.5;
80+ if (o.grid !== false) {
81+ ctx.beginPath();
82+ ctx.moveTo(PAD.l, y);
83+ ctx.lineTo(w - PAD.r, y);
84+ ctx.stroke();
85+ }
86+ ctx.fillText(fy(t), PAD.l - 6, y);
87+ }
88+ ctx.textAlign = 'center';
89+ ctx.textBaseline = 'top';
90+ for (const t of niceTicks(xr[0], xr[1], Math.max(3, Math.floor((w - PAD.l) / 90)))) {
91+ ctx.fillText(fx(t), X(t), h - PAD.b + 5);
92+ }
93+ ctx.strokeStyle = css('--axis');
94+ ctx.beginPath();
95+ ctx.moveTo(PAD.l, h - PAD.b + 0.5);
96+ ctx.lineTo(w - PAD.r, h - PAD.b + 0.5);
97+ ctx.stroke();
98+ ctx.fillStyle = css('--text-secondary');
99+ ctx.fillText(o.xLabel, PAD.l + (w - PAD.l - PAD.r) / 2, h - 14);
100+ ctx.save();
101+ ctx.translate(12, PAD.t + (h - PAD.t - PAD.b) / 2);
102+ ctx.rotate(-Math.PI / 2);
103+ ctx.textBaseline = 'middle';
104+ ctx.fillText(o.yLabel, 0, 0);
105+ ctx.restore();
106+ return { X, Y };
107+}
108+
109+function lowerBound(x: ArrayLike<number>, v: number): number {
110+ let lo = 0;
111+ let hi = x.length;
112+ while (lo < hi) {
113+ const m = (lo + hi) >> 1;
114+ if (x[m] < v) lo = m + 1;
115+ else hi = m;
116+ }
117+ return lo;
118+}
119+
120+export class LinePlot {
121+ private series: Series[] = [];
122+ private opt!: PlotOptions;
123+ private tip: HTMLDivElement;
124+ private hoverX: number | null = null;
125+
126+ constructor(private canvas: HTMLCanvasElement) {
127+ this.tip = document.createElement('div');
128+ this.tip.className = 'tooltip';
129+ this.tip.hidden = true;
130+ canvas.parentElement!.append(this.tip);
131+ canvas.addEventListener('mousemove', (e) => {
132+ this.hoverX = e.offsetX;
133+ this.draw();
134+ });
135+ canvas.addEventListener('mouseleave', () => {
136+ this.hoverX = null;
137+ this.tip.hidden = true;
138+ this.draw();
139+ });
140+ new ResizeObserver(() => this.draw()).observe(canvas);
141+ }
142+
143+ set(series: Series[], opt: PlotOptions) {
144+ this.series = series;
145+ this.opt = opt;
146+ this.draw();
147+ }
148+
149+ private ranges(): { xr: [number, number]; yr: [number, number] } {
150+ let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
151+ for (const s of this.series) {
152+ if (!s.x.length) continue;
153+ x0 = Math.min(x0, s.x[0]);
154+ x1 = Math.max(x1, s.x[s.x.length - 1]);
155+ }
156+ const xr = this.opt.xRange ?? [x0, x1];
157+ for (const s of this.series) {
158+ const a = lowerBound(s.x, xr[0]);
159+ const b = lowerBound(s.x, xr[1]);
160+ for (let i = a; i < Math.min(b + 1, s.y.length); i++) {
161+ y0 = Math.min(y0, s.y[i]);
162+ y1 = Math.max(y1, s.y[i]);
163+ }
164+ }
165+ if (!(y1 > y0)) {
166+ y0 -= 1;
167+ y1 += 1;
168+ }
169+ const m = 0.05 * (y1 - y0);
170+ return { xr, yr: this.opt.yRange ?? [y0 - m, y1 + m] };
171+ }
172+
173+ draw() {
174+ if (!this.opt) return;
175+ const { ctx, w, h } = setupCanvas(this.canvas);
176+ ctx.clearRect(0, 0, w, h);
177+ if (!this.series.length) return;
178+ const { xr, yr } = this.ranges();
179+ const { X, Y } = drawAxes(ctx, w, h, xr, yr, this.opt);
180+ ctx.save();
181+ ctx.beginPath();
182+ ctx.rect(PAD.l, PAD.t, w - PAD.l - PAD.r, h - PAD.t - PAD.b);
183+ ctx.clip();
184+ for (const m of this.opt.markers ?? []) {
185+ if (m.x < xr[0] || m.x > xr[1]) continue;
186+ ctx.strokeStyle = m.color;
187+ ctx.setLineDash([3, 3]);
188+ ctx.lineWidth = 1;
189+ ctx.beginPath();
190+ ctx.moveTo(Math.round(X(m.x)) + 0.5, PAD.t);
191+ ctx.lineTo(Math.round(X(m.x)) + 0.5, h - PAD.b);
192+ ctx.stroke();
193+ }
194+ ctx.setLineDash([]);
195+ ctx.lineWidth = 1.5;
196+ ctx.lineJoin = 'round';
197+ const plotW = w - PAD.l - PAD.r;
198+ for (const s of this.series) {
199+ ctx.strokeStyle = s.color;
200+ ctx.beginPath();
201+ const a = Math.max(0, lowerBound(s.x, xr[0]) - 1);
202+ const b = Math.min(s.x.length - 1, lowerBound(s.x, xr[1]));
203+ if (b - a > 2 * plotW) {
204+ // min/max per pixel column
205+ let col = -1, lo = 0, hi = 0;
206+ const flush = () => {
207+ if (col < 0) return;
208+ ctx.moveTo(PAD.l + col + 0.5, Y(lo));
209+ ctx.lineTo(PAD.l + col + 0.5, Y(hi) - 0.5);
210+ };
211+ for (let i = a; i <= b; i++) {
212+ const c = Math.floor(X(s.x[i]) - PAD.l);
213+ if (c !== col) {
214+ flush();
215+ col = c;
216+ lo = hi = s.y[i];
217+ } else {
218+ lo = Math.min(lo, s.y[i]);
219+ hi = Math.max(hi, s.y[i]);
220+ }
221+ }
222+ flush();
223+ } else {
224+ for (let i = a; i <= b; i++) {
225+ if (i === a) ctx.moveTo(X(s.x[i]), Y(s.y[i]));
226+ else ctx.lineTo(X(s.x[i]), Y(s.y[i]));
227+ }
228+ }
229+ ctx.stroke();
230+ }
231+ ctx.restore();
232+
233+ if (this.hoverX !== null && this.hoverX >= PAD.l && this.hoverX <= w - PAD.r) {
234+ const xv = xr[0] + ((this.hoverX - PAD.l) / plotW) * (xr[1] - xr[0]);
235+ ctx.strokeStyle = css('--axis');
236+ ctx.lineWidth = 1;
237+ ctx.beginPath();
238+ ctx.moveTo(Math.round(this.hoverX) + 0.5, PAD.t);
239+ ctx.lineTo(Math.round(this.hoverX) + 0.5, h - PAD.b);
240+ ctx.stroke();
241+ const lines = [`${this.opt.xLabel}: ${(this.opt.fmtX ?? fmtNum)(xv)}`];
242+ for (const s of this.series) {
243+ const i = Math.min(s.x.length - 1, lowerBound(s.x, xv));
244+ lines.push(`${s.label}: ${(this.opt.fmtY ?? fmtNum)(s.y[i])}`);
245+ }
246+ const near = (this.opt.markers ?? []).filter((m) => Math.abs(X(m.x) - this.hoverX!) < 4);
247+ for (const m of near) lines.push(m.label);
248+ this.tip.textContent = lines.join('\n');
249+ this.tip.hidden = false;
250+ this.tip.style.left = `${Math.min(this.hoverX + 12, w - 180)}px`;
251+ this.tip.style.top = `${PAD.t + 4}px`;
252+ }
253+ }
254+}
src/ui/spectrogram.tsadded+97−0View file
@@ -0,0 +1,97 @@
1+import { stft, type Stft } from './fft';
2+import { css, drawAxes, fmtNum, PAD, setupCanvas } from './plot';
3+
4+function hexToRgb(h: string): [number, number, number] {
5+ const v = parseInt(h.replace('#', ''), 16);
6+ return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
7+}
8+
9+// Sequential ramp read from a CSS custom property: comma-separated hex steps.
10+export function rampLUT(varName: string): Uint8ClampedArray {
11+ const stops = css(varName).split(',').map((s) => hexToRgb(s.trim()));
12+ const lut = new Uint8ClampedArray(256 * 3);
13+ for (let i = 0; i < 256; i++) {
14+ const t = (i / 255) * (stops.length - 1);
15+ const k = Math.min(stops.length - 2, Math.floor(t));
16+ const f = t - k;
17+ for (let c = 0; c < 3; c++) lut[3 * i + c] = stops[k][c] + f * (stops[k + 1][c] - stops[k][c]);
18+ }
19+ return lut;
20+}
21+
22+export class Spectrogram {
23+ private s: Stft | null = null;
24+ fmax = 5000;
25+ range = 80;
26+ private tip: HTMLDivElement;
27+
28+ constructor(private canvas: HTMLCanvasElement) {
29+ this.tip = document.createElement('div');
30+ this.tip.className = 'tooltip';
31+ this.tip.hidden = true;
32+ canvas.parentElement!.append(this.tip);
33+ canvas.addEventListener('mousemove', (e) => this.hover(e.offsetX, e.offsetY));
34+ canvas.addEventListener('mouseleave', () => (this.tip.hidden = true));
35+ new ResizeObserver(() => this.draw()).observe(canvas);
36+ }
37+
38+ setSignal(x: Float32Array, fs: number) {
39+ this.s = stft(x, fs, 2048, Math.max(64, Math.floor(x.length / 1200)));
40+ this.draw();
41+ }
42+
43+ draw() {
44+ const { ctx, w, h } = setupCanvas(this.canvas);
45+ ctx.clearRect(0, 0, w, h);
46+ const s = this.s;
47+ if (!s) return;
48+ const fmax = Math.min(this.fmax, s.fs / 2);
49+ const nb = Math.max(2, Math.round((fmax / s.fs) * s.nfft));
50+ const nf = s.mags.length;
51+ let top = -Infinity;
52+ for (const m of s.mags) for (let k = 0; k <= nb; k++) top = Math.max(top, m[k]);
53+ const lut = rampLUT('--ramp');
54+ const img = new ImageData(nf, nb);
55+ for (let j = 0; j < nf; j++) {
56+ const m = s.mags[j];
57+ for (let k = 0; k < nb; k++) {
58+ const v = Math.max(0, Math.min(1, (m[k] - top + this.range) / this.range));
59+ const li = Math.round(v * 255) * 3;
60+ const p = 4 * ((nb - 1 - k) * nf + j);
61+ img.data[p] = lut[li];
62+ img.data[p + 1] = lut[li + 1];
63+ img.data[p + 2] = lut[li + 2];
64+ img.data[p + 3] = 255;
65+ }
66+ }
67+ const off = document.createElement('canvas');
68+ off.width = nf;
69+ off.height = nb;
70+ off.getContext('2d')!.putImageData(img, 0, 0);
71+ const duration = ((nf - 1) * s.hop) / s.fs;
72+ ctx.imageSmoothingEnabled = true;
73+ ctx.drawImage(off, PAD.l, PAD.t, w - PAD.l - PAD.r, h - PAD.t - PAD.b);
74+ drawAxes(ctx, w, h, [0, duration], [0, fmax], { xLabel: 'time (s)', yLabel: 'frequency (Hz)', grid: false });
75+ }
76+
77+ private hover(px: number, py: number) {
78+ const s = this.s;
79+ if (!s) return;
80+ const w = this.canvas.clientWidth;
81+ const h = this.canvas.clientHeight;
82+ if (px < PAD.l || px > w - PAD.r || py < PAD.t || py > h - PAD.b) {
83+ this.tip.hidden = true;
84+ return;
85+ }
86+ const fmax = Math.min(this.fmax, s.fs / 2);
87+ const fx = (px - PAD.l) / (w - PAD.l - PAD.r);
88+ const fy = 1 - (py - PAD.t) / (h - PAD.t - PAD.b);
89+ const j = Math.min(s.mags.length - 1, Math.round(fx * (s.mags.length - 1)));
90+ const f = fy * fmax;
91+ const k = Math.round((f / s.fs) * s.nfft);
92+ this.tip.textContent = `t: ${fmtNum((j * s.hop) / s.fs)} s\nf: ${fmtNum(f)} Hz\nlevel: ${s.mags[j][k].toFixed(1)} dB`;
93+ this.tip.hidden = false;
94+ this.tip.style.left = `${Math.min(px + 12, w - 140)}px`;
95+ this.tip.style.top = `${PAD.t + 4}px`;
96+ }
97+}
src/ui/wav.tsadded+29−0View file
@@ -0,0 +1,29 @@
1+// 16-bit mono PCM WAV, normalized to the given peak (default -1 dBFS).
2+export function encodeWav(x: Float32Array, fs: number, peak = 0.89): Blob {
3+ let m = 0;
4+ for (let i = 0; i < x.length; i++) m = Math.max(m, Math.abs(x[i]));
5+ const g = m > 0 ? peak / m : 0;
6+ const buf = new ArrayBuffer(44 + 2 * x.length);
7+ const v = new DataView(buf);
8+ const str = (o: number, s: string) => {
9+ for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i));
10+ };
11+ str(0, 'RIFF');
12+ v.setUint32(4, 36 + 2 * x.length, true);
13+ str(8, 'WAVE');
14+ str(12, 'fmt ');
15+ v.setUint32(16, 16, true);
16+ v.setUint16(20, 1, true);
17+ v.setUint16(22, 1, true);
18+ v.setUint32(24, fs, true);
19+ v.setUint32(28, fs * 2, true);
20+ v.setUint16(32, 2, true);
21+ v.setUint16(34, 16, true);
22+ str(36, 'data');
23+ v.setUint32(40, 2 * x.length, true);
24+ for (let i = 0; i < x.length; i++) {
25+ const s = Math.max(-1, Math.min(1, x[i] * g));
26+ v.setInt16(44 + 2 * i, Math.round(s * 32767), true);
27+ }
28+ return new Blob([buf], { type: 'audio/wav' });
29+}
test/string.test.tsadded+71−0View file
@@ -0,0 +1,71 @@
1+import { describe, expect, it } from 'vitest';
2+import { buildExcitation, buildPickup, buildSystem, derive, type StringParams } from '../src/instruments/bead-string/model';
3+import { simulate } from '../src/sim/solver';
4+import { PRESETS } from '../src/instruments/bead-string/presets';
5+
6+const base = (over: Partial<StringParams> = {}): StringParams => ({ ...PRESETS[0].params, ...over });
7+
8+describe('linear modes', () => {
9+ it('match the discrete fixed-fixed formula', () => {
10+ const p = base({ n: 20 });
11+ const d = derive(p);
12+ for (let m = 1; m <= 5; m++) {
13+ const w = 2 * Math.sqrt(p.tension / (p.mass * p.spacing)) * Math.sin((m * Math.PI) / (2 * (p.n + 1)));
14+ expect(d.transverseHz[m - 1]).toBeCloseTo(w / (2 * Math.PI), 6);
15+ }
16+ });
17+ it('sliding ends give the free-free set (no zero mode kept)', () => {
18+ const p = base({ n: 10, leftEnd: 'sliding', rightEnd: 'sliding' });
19+ const d = derive(p);
20+ const w1 = 2 * Math.sqrt(p.tension / (p.mass * p.spacing)) * Math.sin(Math.PI / (2 * p.n));
21+ expect(d.transverseHz[0]).toBeCloseTo(w1 / (2 * Math.PI), 6);
22+ });
23+});
24+
25+function runMode1(p: StringParams, amp: number, duration: number) {
26+ const d = derive(p);
27+ const sys = buildSystem(p, d);
28+ const exc = buildExcitation(p);
29+ exc.u0.fill(0);
30+ for (let i = 0; i < p.n; i++) exc.u0[3 * i + 1] = amp * Math.sin((Math.PI * (i + 1)) / (p.n + 1));
31+ const r = simulate(sys, exc, { duration, fs: 48000, cfl: 0.5, frameRate: 48000, pickup: buildPickup(p) });
32+ return { d, r };
33+}
34+
35+describe('solver', () => {
36+ it('small mode-1 vibration oscillates at the linear frequency', () => {
37+ const p = base({ n: 9, t60Low: 0, t60High: 0 });
38+ const { d, r } = runMode1(p, 1e-6, 0.05);
39+ const mid = 3 * 4 + 1;
40+ const f = d.transverseHz[0];
41+ let err = 0;
42+ for (let k = 0; k < r.frameTimes.length; k++) {
43+ const exact = 1e-6 * Math.cos(2 * Math.PI * f * r.frameTimes[k]);
44+ err = Math.max(err, Math.abs(r.frames[k * 3 * p.n + mid] - exact));
45+ }
46+ expect(err / 1e-6).toBeLessThan(2e-3);
47+ });
48+ it('conserves energy without damping and balances it with damping and a hammer', () => {
49+ for (const damp of [false, true]) {
50+ const p = base({ n: 15, excitation: 'hammer', hammerForce: 5, hammerMs: 1, t60Low: damp ? 0.5 : 0, t60High: damp ? 0.1 : 0 });
51+ const d = derive(p);
52+ const r = simulate(buildSystem(p, d), buildExcitation(p), { duration: 0.1, fs: 48000, cfl: 0.5, frameRate: 1000, pickup: buildPickup(p) });
53+ const emax = Math.max(...r.energy);
54+ const bmax = Math.max(...r.balance.map(Math.abs));
55+ expect(bmax / emax).toBeLessThan(1e-2);
56+ }
57+ });
58+ it('mode 1 decays at the requested T60', () => {
59+ const p = base({ n: 9, t60Low: 0.3, fLow: 0, t60High: 0 });
60+ const { r } = runMode1(p, 1e-6, 0.3);
61+ const mid = 3 * 4 + 1;
62+ const env = (t0: number, t1: number) => {
63+ let m = 0;
64+ for (let k = 0; k < r.frameTimes.length; k++) if (r.frameTimes[k] >= t0 && r.frameTimes[k] < t1) m = Math.max(m, Math.abs(r.frames[k * 3 * p.n + mid]));
65+ return m;
66+ };
67+ const ratio = env(0.25, 0.3) / env(0, 0.05);
68+ expect(20 * Math.log10(ratio)).toBeGreaterThan(-60 * (0.25 / 0.3) - 3);
69+ expect(20 * Math.log10(ratio)).toBeLessThan(-60 * (0.25 / 0.3) + 3);
70+ });
71+});
tsconfig.jsonadded+14−0View file
@@ -0,0 +1,14 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "bundler",
6+ "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
7+ "strict": true,
8+ "noUnusedLocals": true,
9+ "noUnusedParameters": true,
10+ "skipLibCheck": true,
11+ "types": ["vite/client"]
12+ },
13+ "include": ["src", "test"]
14+}
vite.config.tsadded+6−0View file
@@ -0,0 +1,6 @@
1+import { defineConfig } from 'vite';
2+
3+export default defineConfig({
4+ base: './',
5+ worker: { format: 'es' },
6+});