/ proof-of-concept / midiboot
Sign in
proof-of-concept / midiboot
Placeholder instrawave adapter: drum, dulcimer, chime as instruments
The three bundled instrawave instruments register through the generic Instrument interface when WebGPU is present, at default geometry with reduced accuracy (nx 64, 0.4 s room response) so the first bake takes seconds. Bakes are held in memory; notes are onsets with per-event pitch override and velocity-scaled excitation against one shared bake, and traces are resampled from the solver rate. The adapter is confined to src/instruments/instrawave/ so the planned successor swaps in without touching the rest. Adds a Dawn-based GPU smoke test (npm run test:gpu) and surfaces play/record/export failures in the status bar.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 8ad9830ed4ec parent ff3d286 Browse files
10 changed files+278−467
README.mdmodified+4−1View file
@@ -18,6 +18,8 @@ interface Instrument {
1818
1919 There are two paths. The offline path, `render`, receives every note of a track with times already converted to seconds and produces one mono buffer; it may be async and arbitrarily expensive, and playback, mixing, and WAV export all go through it. The live path, `noteOn`, is optional and exists for low-latency monitoring while recording and for previewing notes from the piano roll; an instrument without it still works everywhere, with a plain beep standing in for monitoring. New instruments are added with `registerInstrument` in [src/instruments/registry.ts](src/instruments/registry.ts); tracks refer to instruments by id, so a song whose instrument is missing falls back to a builtin rather than failing.
2020
21+Alongside the builtins there is a placeholder adapter for the three [instrawave](../instrawave) instruments (drum, dulcimer, chime), registered when the browser has WebGPU. Each appears at its default geometry with deliberately reduced accuracy so the first bake takes seconds rather than minutes; the bake is held in memory for the session, and every note becomes one stimulus with the pitch parameter overridden to the note's frequency and the excitation scaled by velocity, against that one shared bake. We emphasize that this is scaffolding: instrawave is itself a prototype, the adapter lives entirely in [src/instruments/instrawave/](src/instruments/instrawave/), and it will be swapped for a successor without touching the rest of the application. On a machine without WebGPU the adapter registers nothing and a song bound to these instruments falls back to a builtin.
22+
2123 The four builtin instruments (Karplus-Strong pluck, FM electric piano, a filtered square lead, and a General MIDI drum kit) are constructed from pure per-note DSP functions in [src/instruments/dsp.ts](src/instruments/dsp.ts). These are deterministic, with noise drawn from a PRNG seeded by pitch, so the node test harness checks the actual audio. The `instrumentFromNoteDsp` helper derives both paths from one such function; a custom instrument is free to ignore it and implement the interface directly.
2224
2325 ## Recording
@@ -43,10 +45,11 @@ npm install
4345 npm run dev # vite dev server
4446 npm run build # typecheck and bundle
4547 npm test # node-side tests (no browser, no audio device)
48+npm run test:gpu # instrawave adapter smoke test on desktop WebGPU (Dawn)
4649 ```
4750
4851 The tests cover the MIDI writer/parser round trip, tempo map arithmetic, quantize and transpose, mixing rules, and the determinism and boundedness of the DSP.
4952
5053 ## Limitations and future work
5154
52-The mixdown is mono, matching the mono instrument interface; a stereo render path is a straightforward extension. There is no loop region, no controller or pitch bend data, and editing the tempo of a song that already has a tempo map is not supported (the bpm field edits only single-tempo songs rather than silently flattening the map). The live path plays each note as a pre-rendered buffer with a gain release, which is fine for monitoring but is not a substitute for a real streaming voice. Future work will add an instrawave adapter as the first external instrument, at which point render latency and cache persistence (IndexedDB rather than memory) become the interesting problems.
55+The mixdown is mono, matching the mono instrument interface; a stereo render path is a straightforward extension. There is no loop region, no controller or pitch bend data, and editing the tempo of a song that already has a tempo map is not supported (the bpm field edits only single-tempo songs rather than silently flattening the map). The live path plays each note as a pre-rendered buffer with a gain release, which is fine for monitoring but is not a substitute for a real streaming voice. The instrawave adapter is a placeholder: geometry, tuning, and accuracy are fixed, bakes are not persisted, and there is no live monitoring voice, so recording against an instrawave track monitors through a plain beep. Making instrument instances editable and persisting their bakes in IndexedDB are where this goes next, most likely against instrawave's successor rather than instrawave itself.
package-lock.jsonmodified+30−458View file
@@ -9,6 +9,7 @@
99 "version": "0.1.0",
1010 "license": "Apache-2.0",
1111 "dependencies": {
12+ "instrawave": "file:../instrawave",
1213 "react": "^18.3.1",
1314 "react-dom": "^18.3.1"
1415 },
@@ -17,6 +18,7 @@
1718 "@types/react": "^18.3.3",
1819 "@types/react-dom": "^18.3.0",
1920 "@vitejs/plugin-react": "^4.3.1",
21+ "@webgpu/types": "^0.1.44",
2022 "typescript": "^5.5.0",
2123 "vite": "^5.4.0",
2224 "vite-node": "^6.0.0"
@@ -25,6 +27,23 @@
2527 "node": ">=22.6"
2628 }
2729 },
30+ "../instrawave": {
31+ "version": "0.1.0",
32+ "license": "Apache-2.0",
33+ "devDependencies": {
34+ "@types/node": "^26.1.1",
35+ "@webgpu/types": "^0.1.44",
36+ "typescript": "^5.5.0",
37+ "vite": "^5.4.0",
38+ "vite-node": "^6.0.0"
39+ },
40+ "engines": {
41+ "node": ">=22.6"
42+ },
43+ "optionalDependencies": {
44+ "webgpu": "^0.4.0"
45+ }
46+ },
2847 "node_modules/@babel/code-frame": {
2948 "version": "7.29.7",
3049 "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -596,22 +615,6 @@
596615 "node": ">=12"
597616 }
598617 },
599- "node_modules/@esbuild/netbsd-arm64": {
600- "version": "0.28.2",
601- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
602- "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
603- "cpu": [
604- "arm64"
605- ],
606- "extraneous": true,
607- "license": "MIT",
608- "os": [
609- "netbsd"
610- ],
611- "engines": {
612- "node": ">=18"
613- }
614- },
615618 "node_modules/@esbuild/netbsd-x64": {
616619 "version": "0.21.5",
617620 "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
@@ -629,22 +632,6 @@
629632 "node": ">=12"
630633 }
631634 },
632- "node_modules/@esbuild/openbsd-arm64": {
633- "version": "0.28.2",
634- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
635- "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
636- "cpu": [
637- "arm64"
638- ],
639- "extraneous": true,
640- "license": "MIT",
641- "os": [
642- "openbsd"
643- ],
644- "engines": {
645- "node": ">=18"
646- }
647- },
648635 "node_modules/@esbuild/openbsd-x64": {
649636 "version": "0.21.5",
650637 "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
@@ -662,22 +649,6 @@
662649 "node": ">=12"
663650 }
664651 },
665- "node_modules/@esbuild/openharmony-arm64": {
666- "version": "0.28.2",
667- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
668- "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
669- "cpu": [
670- "arm64"
671- ],
672- "extraneous": true,
673- "license": "MIT",
674- "os": [
675- "openharmony"
676- ],
677- "engines": {
678- "node": ">=18"
679- }
680- },
681652 "node_modules/@esbuild/sunos-x64": {
682653 "version": "0.21.5",
683654 "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
@@ -1606,6 +1577,13 @@
16061577 "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
16071578 }
16081579 },
1580+ "node_modules/@webgpu/types": {
1581+ "version": "0.1.72",
1582+ "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz",
1583+ "integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==",
1584+ "dev": true,
1585+ "license": "BSD-3-Clause"
1586+ },
16091587 "node_modules/baseline-browser-mapping": {
16101588 "version": "2.11.20",
16111589 "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz",
@@ -1832,6 +1810,10 @@
18321810 "node": ">=6.9.0"
18331811 }
18341812 },
1813+ "node_modules/instrawave": {
1814+ "resolved": "../instrawave",
1815+ "link": true
1816+ },
18351817 "node_modules/js-tokens": {
18361818 "version": "4.0.0",
18371819 "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -2568,416 +2550,6 @@
25682550 "url": "https://opencollective.com/antfu"
25692551 }
25702552 },
2571- "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": {
2572- "version": "0.28.2",
2573- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
2574- "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
2575- "cpu": [
2576- "ppc64"
2577- ],
2578- "extraneous": true,
2579- "license": "MIT",
2580- "os": [
2581- "aix"
2582- ],
2583- "engines": {
2584- "node": ">=18"
2585- }
2586- },
2587- "node_modules/vite-node/node_modules/@esbuild/android-arm": {
2588- "version": "0.28.2",
2589- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
2590- "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
2591- "cpu": [
2592- "arm"
2593- ],
2594- "extraneous": true,
2595- "license": "MIT",
2596- "os": [
2597- "android"
2598- ],
2599- "engines": {
2600- "node": ">=18"
2601- }
2602- },
2603- "node_modules/vite-node/node_modules/@esbuild/android-arm64": {
2604- "version": "0.28.2",
2605- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
2606- "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
2607- "cpu": [
2608- "arm64"
2609- ],
2610- "extraneous": true,
2611- "license": "MIT",
2612- "os": [
2613- "android"
2614- ],
2615- "engines": {
2616- "node": ">=18"
2617- }
2618- },
2619- "node_modules/vite-node/node_modules/@esbuild/android-x64": {
2620- "version": "0.28.2",
2621- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
2622- "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
2623- "cpu": [
2624- "x64"
2625- ],
2626- "extraneous": true,
2627- "license": "MIT",
2628- "os": [
2629- "android"
2630- ],
2631- "engines": {
2632- "node": ">=18"
2633- }
2634- },
2635- "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": {
2636- "version": "0.28.2",
2637- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
2638- "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
2639- "cpu": [
2640- "arm64"
2641- ],
2642- "extraneous": true,
2643- "license": "MIT",
2644- "os": [
2645- "darwin"
2646- ],
2647- "engines": {
2648- "node": ">=18"
2649- }
2650- },
2651- "node_modules/vite-node/node_modules/@esbuild/darwin-x64": {
2652- "version": "0.28.2",
2653- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
2654- "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
2655- "cpu": [
2656- "x64"
2657- ],
2658- "extraneous": true,
2659- "license": "MIT",
2660- "os": [
2661- "darwin"
2662- ],
2663- "engines": {
2664- "node": ">=18"
2665- }
2666- },
2667- "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": {
2668- "version": "0.28.2",
2669- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
2670- "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
2671- "cpu": [
2672- "arm64"
2673- ],
2674- "extraneous": true,
2675- "license": "MIT",
2676- "os": [
2677- "freebsd"
2678- ],
2679- "engines": {
2680- "node": ">=18"
2681- }
2682- },
2683- "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": {
2684- "version": "0.28.2",
2685- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
2686- "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
2687- "cpu": [
2688- "x64"
2689- ],
2690- "extraneous": true,
2691- "license": "MIT",
2692- "os": [
2693- "freebsd"
2694- ],
2695- "engines": {
2696- "node": ">=18"
2697- }
2698- },
2699- "node_modules/vite-node/node_modules/@esbuild/linux-arm": {
2700- "version": "0.28.2",
2701- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
2702- "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
2703- "cpu": [
2704- "arm"
2705- ],
2706- "extraneous": true,
2707- "license": "MIT",
2708- "os": [
2709- "linux"
2710- ],
2711- "engines": {
2712- "node": ">=18"
2713- }
2714- },
2715- "node_modules/vite-node/node_modules/@esbuild/linux-arm64": {
2716- "version": "0.28.2",
2717- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
2718- "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
2719- "cpu": [
2720- "arm64"
2721- ],
2722- "extraneous": true,
2723- "license": "MIT",
2724- "os": [
2725- "linux"
2726- ],
2727- "engines": {
2728- "node": ">=18"
2729- }
2730- },
2731- "node_modules/vite-node/node_modules/@esbuild/linux-ia32": {
2732- "version": "0.28.2",
2733- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
2734- "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
2735- "cpu": [
2736- "ia32"
2737- ],
2738- "extraneous": true,
2739- "license": "MIT",
2740- "os": [
2741- "linux"
2742- ],
2743- "engines": {
2744- "node": ">=18"
2745- }
2746- },
2747- "node_modules/vite-node/node_modules/@esbuild/linux-loong64": {
2748- "version": "0.28.2",
2749- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
2750- "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
2751- "cpu": [
2752- "loong64"
2753- ],
2754- "extraneous": true,
2755- "license": "MIT",
2756- "os": [
2757- "linux"
2758- ],
2759- "engines": {
2760- "node": ">=18"
2761- }
2762- },
2763- "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": {
2764- "version": "0.28.2",
2765- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
2766- "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
2767- "cpu": [
2768- "mips64el"
2769- ],
2770- "extraneous": true,
2771- "license": "MIT",
2772- "os": [
2773- "linux"
2774- ],
2775- "engines": {
2776- "node": ">=18"
2777- }
2778- },
2779- "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": {
2780- "version": "0.28.2",
2781- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
2782- "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
2783- "cpu": [
2784- "ppc64"
2785- ],
2786- "extraneous": true,
2787- "license": "MIT",
2788- "os": [
2789- "linux"
2790- ],
2791- "engines": {
2792- "node": ">=18"
2793- }
2794- },
2795- "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": {
2796- "version": "0.28.2",
2797- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
2798- "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
2799- "cpu": [
2800- "riscv64"
2801- ],
2802- "extraneous": true,
2803- "license": "MIT",
2804- "os": [
2805- "linux"
2806- ],
2807- "engines": {
2808- "node": ">=18"
2809- }
2810- },
2811- "node_modules/vite-node/node_modules/@esbuild/linux-s390x": {
2812- "version": "0.28.2",
2813- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
2814- "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
2815- "cpu": [
2816- "s390x"
2817- ],
2818- "extraneous": true,
2819- "license": "MIT",
2820- "os": [
2821- "linux"
2822- ],
2823- "engines": {
2824- "node": ">=18"
2825- }
2826- },
2827- "node_modules/vite-node/node_modules/@esbuild/linux-x64": {
2828- "version": "0.28.2",
2829- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
2830- "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
2831- "cpu": [
2832- "x64"
2833- ],
2834- "extraneous": true,
2835- "license": "MIT",
2836- "os": [
2837- "linux"
2838- ],
2839- "engines": {
2840- "node": ">=18"
2841- }
2842- },
2843- "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": {
2844- "version": "0.28.2",
2845- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
2846- "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
2847- "cpu": [
2848- "x64"
2849- ],
2850- "extraneous": true,
2851- "license": "MIT",
2852- "os": [
2853- "netbsd"
2854- ],
2855- "engines": {
2856- "node": ">=18"
2857- }
2858- },
2859- "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": {
2860- "version": "0.28.2",
2861- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
2862- "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
2863- "cpu": [
2864- "x64"
2865- ],
2866- "extraneous": true,
2867- "license": "MIT",
2868- "os": [
2869- "openbsd"
2870- ],
2871- "engines": {
2872- "node": ">=18"
2873- }
2874- },
2875- "node_modules/vite-node/node_modules/@esbuild/sunos-x64": {
2876- "version": "0.28.2",
2877- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
2878- "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
2879- "cpu": [
2880- "x64"
2881- ],
2882- "extraneous": true,
2883- "license": "MIT",
2884- "os": [
2885- "sunos"
2886- ],
2887- "engines": {
2888- "node": ">=18"
2889- }
2890- },
2891- "node_modules/vite-node/node_modules/@esbuild/win32-arm64": {
2892- "version": "0.28.2",
2893- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
2894- "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
2895- "cpu": [
2896- "arm64"
2897- ],
2898- "extraneous": true,
2899- "license": "MIT",
2900- "os": [
2901- "win32"
2902- ],
2903- "engines": {
2904- "node": ">=18"
2905- }
2906- },
2907- "node_modules/vite-node/node_modules/@esbuild/win32-ia32": {
2908- "version": "0.28.2",
2909- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
2910- "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
2911- "cpu": [
2912- "ia32"
2913- ],
2914- "extraneous": true,
2915- "license": "MIT",
2916- "os": [
2917- "win32"
2918- ],
2919- "engines": {
2920- "node": ">=18"
2921- }
2922- },
2923- "node_modules/vite-node/node_modules/@esbuild/win32-x64": {
2924- "version": "0.28.2",
2925- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
2926- "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
2927- "cpu": [
2928- "x64"
2929- ],
2930- "extraneous": true,
2931- "license": "MIT",
2932- "os": [
2933- "win32"
2934- ],
2935- "engines": {
2936- "node": ">=18"
2937- }
2938- },
2939- "node_modules/vite-node/node_modules/esbuild": {
2940- "version": "0.28.2",
2941- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
2942- "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
2943- "extraneous": true,
2944- "hasInstallScript": true,
2945- "license": "MIT",
2946- "bin": {
2947- "esbuild": "bin/esbuild"
2948- },
2949- "engines": {
2950- "node": ">=18"
2951- },
2952- "optionalDependencies": {
2953- "@esbuild/aix-ppc64": "0.28.2",
2954- "@esbuild/android-arm": "0.28.2",
2955- "@esbuild/android-arm64": "0.28.2",
2956- "@esbuild/android-x64": "0.28.2",
2957- "@esbuild/darwin-arm64": "0.28.2",
2958- "@esbuild/darwin-x64": "0.28.2",
2959- "@esbuild/freebsd-arm64": "0.28.2",
2960- "@esbuild/freebsd-x64": "0.28.2",
2961- "@esbuild/linux-arm": "0.28.2",
2962- "@esbuild/linux-arm64": "0.28.2",
2963- "@esbuild/linux-ia32": "0.28.2",
2964- "@esbuild/linux-loong64": "0.28.2",
2965- "@esbuild/linux-mips64el": "0.28.2",
2966- "@esbuild/linux-ppc64": "0.28.2",
2967- "@esbuild/linux-riscv64": "0.28.2",
2968- "@esbuild/linux-s390x": "0.28.2",
2969- "@esbuild/linux-x64": "0.28.2",
2970- "@esbuild/netbsd-arm64": "0.28.2",
2971- "@esbuild/netbsd-x64": "0.28.2",
2972- "@esbuild/openbsd-arm64": "0.28.2",
2973- "@esbuild/openbsd-x64": "0.28.2",
2974- "@esbuild/openharmony-arm64": "0.28.2",
2975- "@esbuild/sunos-x64": "0.28.2",
2976- "@esbuild/win32-arm64": "0.28.2",
2977- "@esbuild/win32-ia32": "0.28.2",
2978- "@esbuild/win32-x64": "0.28.2"
2979- }
2980- },
29812553 "node_modules/vite-node/node_modules/vite": {
29822554 "version": "8.2.2",
29832555 "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
package.jsonmodified+6−3View file
@@ -10,11 +10,13 @@
1010 "scripts": {
1111 "dev": "vite",
1212 "build": "tsc --noEmit && vite build",
13- "test": "vite-node scripts/test-node.ts"
13+ "test": "vite-node scripts/test-node.ts",
14+ "test:gpu": "vite-node scripts/test-gpu.ts"
1415 },
1516 "dependencies": {
1617 "react": "^18.3.1",
17- "react-dom": "^18.3.1"
18+ "react-dom": "^18.3.1",
19+ "instrawave": "file:../instrawave"
1820 },
1921 "devDependencies": {
2022 "@types/node": "^26.1.1",
@@ -23,6 +25,7 @@
2325 "@vitejs/plugin-react": "^4.3.1",
2426 "typescript": "^5.5.0",
2527 "vite": "^5.4.0",
26- "vite-node": "^6.0.0"
28+ "vite-node": "^6.0.0",
29+ "@webgpu/types": "^0.1.44"
2730 }
2831 }
scripts/test-gpu.tsadded+72−0View file
@@ -0,0 +1,72 @@
1+/**
2+ * GPU smoke test for the placeholder instrawave adapter, on desktop
3+ * WebGPU (Google Dawn) through instrawave's node shim: bake the drum once
4+ * at the placeholder accuracy, render two notes through the midiboot
5+ * Instrument interface, and check the result is shaped like audio. Run
6+ * with `npm run test:gpu`; exits 0 with a note when no GPU is available.
7+ */
8+import { installWebGpu, errMsg } from 'instrawave/scripts/nodeWebGpu.ts';
9+
10+try {
11+ console.log(await installWebGpu());
12+} catch (e) {
13+ console.log(`skipped: ${errMsg(e)}`);
14+ process.exit(0);
15+}
16+
17+const { createInstrawaveInstruments } = await import('../src/instruments/instrawave/adapter.ts');
18+
19+let failures = 0;
20+function check(cond: boolean, msg: string): void {
21+ console.log(`${cond ? 'ok ' : 'FAIL'} ${msg}`);
22+ if (!cond) failures++;
23+}
24+
25+const drum = createInstrawaveInstruments().find((i) => i.id === 'iw-drum')!;
26+const SR = 44100;
27+const len = Math.round(SR * 2.0);
28+const t0 = performance.now();
29+const out = (await drum.render({
30+ notes: [
31+ { timeSec: 0.1, durationSec: 0.25, pitch: 48, velocity: 0.9 },
32+ { timeSec: 0.9, durationSec: 0.25, pitch: 55, velocity: 0.6 },
33+ ],
34+ sampleRate: SR,
35+ lengthSamples: len,
36+})) as Float32Array;
37+console.log(`bake + render in ${((performance.now() - t0) / 1000).toFixed(1)} s`);
38+
39+check(out.length === len, `trace length ${out.length} = ${len}`);
40+let finite = true;
41+for (let i = 0; i < out.length; i++) if (!Number.isFinite(out[i])) { finite = false; break; }
42+check(finite, 'all samples finite');
43+const seg = (a: number, b: number) => {
44+ let p = 0;
45+ for (let i = Math.round(a * SR); i < Math.round(b * SR); i++) p = Math.max(p, Math.abs(out[i]));
46+ return p;
47+};
48+// The raw trace is at mic-pressure scale (peaks around 1e-5); the app
49+// normalizes per track, so the checks here are relative to the peak.
50+const peakAll = seg(0, 2.0);
51+check(peakAll > 1e-7, `trace is not silent (peak ${peakAll.toExponential(2)})`);
52+check(seg(0, 0.09) < peakAll * 1e-3, 'silent before the first strike');
53+check(seg(0.12, 0.8) > peakAll * 0.1, 'first strike sounds');
54+check(seg(0.92, 1.8) > peakAll * 0.05, 'second strike sounds');
55+
56+// The render is deterministic against the same bake.
57+const again = (await drum.render({
58+ notes: [{ timeSec: 0.1, durationSec: 0.25, pitch: 48, velocity: 0.9 }],
59+ sampleRate: SR,
60+ lengthSamples: SR,
61+})) as Float32Array;
62+const again2 = (await drum.render({
63+ notes: [{ timeSec: 0.1, durationSec: 0.25, pitch: 48, velocity: 0.9 }],
64+ sampleRate: SR,
65+ lengthSamples: SR,
66+})) as Float32Array;
67+let same = again.length === again2.length;
68+if (same) for (let i = 0; i < again.length; i++) if (again[i] !== again2[i]) { same = false; break; }
69+check(same, 'repeat render is bit-identical');
70+
71+console.log(failures === 0 ? 'GPU smoke test passed' : `${failures} failure(s)`);
72+process.exit(failures > 0 ? 1 : 0);
src/components/App.tsxmodified+6−2View file
@@ -123,8 +123,12 @@ export function App() {
123123 disabled={!song}
124124 onClick={async () => {
125125 if (!song) return;
126- const wav = await player.exportWav(song);
127- download(`${app.rec?.name ?? 'song'}.wav`, wav, 'audio/wav');
126+ try {
127+ const wav = await player.exportWav(song);
128+ download(`${app.rec?.name ?? 'song'}.wav`, wav, 'audio/wav');
129+ } catch (err) {
130+ store.setStatus(`Export failed: ${err instanceof Error ? err.message : String(err)}`);
131+ }
128132 }}
129133 >Export WAV</button>
130134 <span className="spacer" />
src/instruments/instrawave/adapter.tsadded+130−0View file
@@ -0,0 +1,130 @@
1+/**
2+ * Placeholder instrawave adapter: the three bundled instrawave instruments
3+ * (drum, dulcimer, chime) exposed through midiboot's Instrument interface,
4+ * each at its default geometry with reduced accuracy so the first bake is
5+ * quick rather than faithful. Instrawave is itself a prototype and this
6+ * seam will be swapped for its successor; nothing outside this directory
7+ * knows instrawave exists, so the swap is confined here.
8+ *
9+ * What the placeholder deliberately leaves out: instance editing (geometry
10+ * and tuning are fixed at the file defaults), bake persistence (bakes live
11+ * in memory and are redone per page load), and a live noteOn voice
12+ * (monitoring falls back to the player's beep). Notes are treated as pure
13+ * onsets, the way a struck drum works: each note becomes one stimulus with
14+ * the pitch parameter overridden to the note's frequency (clamped to the
15+ * parameter's range) and the excitation strength scaled linearly by
16+ * velocity, against one shared bake.
17+ */
18+import { requestDevice } from 'instrawave/src/host/device.ts';
19+import { InstrumentSession } from 'instrawave/src/host/session.ts';
20+import type { Bake } from 'instrawave/src/host/bake.ts';
21+import { parseInstrument, paramValues, type InstrumentFile, type StimulusEvent } from 'instrawave/src/format.ts';
22+import drumJson from 'instrawave/instruments/drum.instrument.json';
23+import dulcimerJson from 'instrawave/instruments/dulcimer.instrument.json';
24+import chimeJson from 'instrawave/instruments/chime.instrument.json';
25+import type { Instrument, RenderRequest } from '../types.ts';
26+import { registerInstrument } from '../registry.ts';
27+import { midiToFreq } from '../dsp.ts';
28+
29+/** Coarse grid, short room response: a bake in seconds, not minutes. */
30+const PLACEHOLDER_ACCURACY: Record<string, number> = { nx: 64, ir_seconds: 0.4 };
31+
32+type StatusFn = (msg: string) => void;
33+let onStatus: StatusFn = () => {};
34+export function setInstrawaveStatus(cb: StatusFn): void {
35+ onStatus = cb;
36+}
37+
38+let devicePromise: Promise<GPUDevice> | null = null;
39+function ensureDevice(): Promise<GPUDevice> {
40+ return (devicePromise ??= requestDevice());
41+}
42+
43+function resampleLinear(x: Float32Array, srFrom: number, srTo: number, outLen: number): Float32Array {
44+ const out = new Float32Array(outLen);
45+ if (x.length === 0) return out;
46+ for (let i = 0; i < outLen; i++) {
47+ const s = (i * srFrom) / srTo;
48+ const k = Math.floor(s);
49+ if (k >= x.length - 1) break;
50+ const f = s - k;
51+ out[i] = (1 - f) * x[k] + f * x[k + 1];
52+ }
53+ return out;
54+}
55+
56+class InstrawaveInstrument implements Instrument {
57+ readonly values: Record<string, number>;
58+ private readonly pitchKey: string;
59+ private readonly strengthKey: string;
60+ private bake: Bake | null = null;
61+ private queue: Promise<unknown> = Promise.resolve();
62+
63+ constructor(
64+ readonly id: string,
65+ readonly name: string,
66+ readonly file: InstrumentFile,
67+ ) {
68+ this.values = paramValues(file, PLACEHOLDER_ACCURACY);
69+ this.pitchKey = file.render.eventParams.includes('f0') ? 'f0' : 'f1';
70+ this.strengthKey = file.render.eventParams.includes('vel') ? 'vel' : 'amp';
71+ }
72+
73+ render(req: RenderRequest): Promise<Float32Array> {
74+ // One GPU render at a time per instrument; concurrent tracks queue.
75+ const run = this.queue.then(() => this.renderNow(req));
76+ this.queue = run.catch(() => undefined);
77+ return run;
78+ }
79+
80+ private async renderNow(req: RenderRequest): Promise<Float32Array> {
81+ if (req.notes.length === 0) return new Float32Array(req.lengthSamples);
82+ const device = await ensureDevice();
83+ const session = new InstrumentSession(device, this.file, this.values);
84+ if (!this.bake) {
85+ try {
86+ this.bake = await session.precompute((done, total) =>
87+ onStatus(`baking ${this.name}: ${done}/${total}`),
88+ );
89+ } finally {
90+ onStatus('');
91+ }
92+ }
93+ const spec = this.file.params.find((p) => p.key === this.pitchKey)!;
94+ let clamped = 0;
95+ const events: StimulusEvent[] = req.notes.map((n) => {
96+ const f = midiToFreq(n.pitch);
97+ const v = Math.max(spec.min, Math.min(spec.max, f));
98+ if (v !== f) clamped++;
99+ return { t: n.timeSec, set: { [this.pitchKey]: v, [this.strengthKey]: this.values[this.strengthKey] * n.velocity } };
100+ });
101+ if (clamped > 0) console.warn(`${this.name}: ${clamped} note(s) clamped to the ${spec.min}..${spec.max} Hz pitch range`);
102+ const seconds = req.lengthSamples / req.sampleRate;
103+ let trace: Float32Array;
104+ let dt: number;
105+ try {
106+ const r = await session.render(this.bake, seconds, events, [], (done, total) =>
107+ onStatus(`rendering ${this.name}: ${done}/${total}`),
108+ );
109+ trace = r.trace;
110+ dt = r.dt;
111+ } finally {
112+ onStatus('');
113+ }
114+ return resampleLinear(trace, 1 / dt, req.sampleRate, req.lengthSamples);
115+ }
116+}
117+
118+export function createInstrawaveInstruments(): Instrument[] {
119+ const files = [drumJson, dulcimerJson, chimeJson].map((j) => parseInstrument(JSON.stringify(j)));
120+ return files.map((f) => new InstrawaveInstrument(`iw-${f.name}`, `Instrawave ${f.name} (GPU)`, f));
121+}
122+
123+/** Registers the placeholder instruments when WebGPU is available;
124+ * returns whether it did. Without WebGPU nothing is registered and a song
125+ * bound to these ids falls back to a builtin. */
126+export function registerInstrawaveInstruments(): boolean {
127+ if (typeof navigator === 'undefined' || !navigator.gpu) return false;
128+ for (const ins of createInstrawaveInstruments()) registerInstrument(ins);
129+ return true;
130+}
src/main.tsxmodified+3−0View file
@@ -1,7 +1,10 @@
11 import { createRoot } from 'react-dom/client';
22 import { App } from './components/App.tsx';
33 import { store } from './state/store.ts';
4+import { registerInstrawaveInstruments, setInstrawaveStatus } from './instruments/instrawave/adapter.ts';
45 import './styles.css';
56
7+registerInstrawaveInstruments();
8+setInstrawaveStatus((msg) => store.setStatus(msg));
69 void store.init();
710 createRoot(document.getElementById('root')!).render(<App />);
src/state/store.tsmodified+17−2View file
@@ -62,6 +62,8 @@ class Store {
6262
6363 song(): Song | null { return this.state.rec?.song ?? null; }
6464
65+ setStatus(msg: string): void { this.set({ status: msg }); }
66+
6567 tempoMap(): TempoMap | null {
6668 const song = this.song();
6769 return song ? new TempoMap(song.ppq, song.tempo, song.timeSig) : null;
@@ -306,7 +308,14 @@ class Store {
306308 const song = this.song();
307309 if (!song) return;
308310 if (player.state !== 'stopped') this.stopTransport();
309- else await player.play(song);
311+ else {
312+ try {
313+ await player.play(song);
314+ } catch (err) {
315+ player.stop();
316+ this.set({ status: `Playback failed: ${err instanceof Error ? err.message : String(err)}` });
317+ }
318+ }
310319 }
311320
312321 async record(): Promise<void> {
@@ -316,7 +325,13 @@ class Store {
316325 const armed = this.state.armedTrackId ?? this.state.activeTrackId;
317326 if (!armed) { this.set({ status: 'Record: no track to record into' }); return; }
318327 this.set({ armedTrackId: armed });
319- await player.play(song, { record: true, countInBars: 1 });
328+ try {
329+ await player.play(song, { record: true, countInBars: 1 });
330+ } catch (err) {
331+ player.stop();
332+ this.set({ status: `Record failed: ${err instanceof Error ? err.message : String(err)}` });
333+ return;
334+ }
320335 this.recorder.start();
321336 }
322337
tsconfig.jsonmodified+1−1View file
@@ -12,7 +12,7 @@
1212 "noEmit": true,
1313 "skipLibCheck": true,
1414 "lib": ["ES2022", "DOM", "DOM.Iterable"],
15- "types": ["vite/client", "node"]
15+ "types": ["@webgpu/types", "vite/client", "node"]
1616 },
1717 "include": ["src", "scripts"]
1818 }
vite.config.tsmodified+9−0View file
@@ -1,8 +1,17 @@
11 import { defineConfig } from 'vite';
22 import react from '@vitejs/plugin-react';
3+import { realpathSync } from 'node:fs';
4+import { resolve } from 'node:path';
5+
6+// instrawave is a file: dependency on the sibling checkout (the vault
7+// layout); it is TypeScript source behind a symlink, so it must be served
8+// from its real path and kept out of prebundling.
9+const instrawave = realpathSync(resolve(import.meta.dirname, '../instrawave'));
310
411 export default defineConfig({
512 base: './',
613 plugins: [react()],
14+ optimizeDeps: { exclude: ['instrawave'] },
15+ server: { fs: { allow: [import.meta.dirname, instrawave] } },
716 build: { target: 'es2022' },
817 });
↑↓ move↵openescclose