Skip to content

Commit 9504bac

Browse files
authored
feat: add WAM (Web Audio Module) 2.0 plugin hosting support (#1341)
Implement complete WAM 2.0 plugin hosting infrastructure for the web DAW.\n\n- WAMHost: initializes WAM environment on AudioWorklet thread\n- WAMPluginAdapter: wraps WAM instances as WAPPlugin for audio chain integration\n- WAMCatalog: curated plugin list with search/filter + custom URL loading\n- wamStore: Zustand store for plugin lifecycle, parameters, presets\n- Full UI: WAMPluginBrowser, WAMPluginPanel, WAMSidePanel\n- URL validation (rejects javascript:/data:/blob:, http restricted to localhost)\n- Error cleanup for partial plugin loads (prevents AudioWorklet leaks)\n- 74 unit tests covering all new modules\n\nCloses #1341
1 parent 1df63c4 commit 9504bac

22 files changed

Lines changed: 2909 additions & 3 deletions

package-lock.json

Lines changed: 74 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
"@strudel/tonal": "^1.2.6",
4141
"@strudel/transpiler": "^1.2.6",
4242
"@strudel/webaudio": "^1.3.0",
43+
"@webaudiomodules/api": "^2.0.0-alpha.6",
44+
"@webaudiomodules/sdk": "^0.0.12",
4345
"@tauri-apps/api": "^2.10.1",
4446
"@xterm/addon-canvas": "^0.7.0",
4547
"@xterm/addon-fit": "^0.11.0",

src/components/layout/EditorShell.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const ClipInspectorPanel = lazy(() => import('../timeline/ClipInspectorPanel').t
3636
const LoopBrowser = lazy(() => import('../assets/LoopBrowser').then(m => ({ default: m.LoopBrowser })));
3737
const SmartControlsPanel = lazy(() => import('../controls/SmartControlsPanel').then(m => ({ default: m.SmartControlsPanel })));
3838
const VST3SidePanel = lazy(() => import('../plugins/VST3SidePanel').then(m => ({ default: m.VST3SidePanel })));
39+
const WAMSidePanel = lazy(() => import('../plugins/WAMSidePanel').then(m => ({ default: m.WAMSidePanel })));
3940

4041
// Lazy-loaded dialogs (code-split, loaded on first use)
4142
const InstrumentPicker = lazy(() => import('../dialogs/InstrumentPicker').then(m => ({ default: m.InstrumentPicker })));
@@ -203,6 +204,7 @@ export function EditorShell() {
203204
{project && <ErrorBoundary name="ArrangementAssistant"><Suspense fallback={null}><ArrangementAssistantPanel /></Suspense></ErrorBoundary>}
204205
{project && <ErrorBoundary name="ClipInspector"><Suspense fallback={null}><ClipInspectorPanel /></Suspense></ErrorBoundary>}
205206
{project && <Suspense fallback={null}><VST3SidePanel /></Suspense>}
207+
{project && <Suspense fallback={null}><WAMSidePanel /></Suspense>}
206208
{project && showModelLibrary && <Suspense fallback={null}><ModelLibraryPanel /></Suspense>}
207209
{project && showCustomModels && <Suspense fallback={null}><CustomModelsPanel /></Suspense>}
208210
{project && showVirtualKeyboard && <Suspense fallback={null}><VirtualKeyboard /></Suspense>}

src/components/layout/Toolbar.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,32 @@ function ControlBarButton({
566566
return btn;
567567
}
568568

569+
function WAMPluginButton() {
570+
const showWAMPanel = useUIStore((s) => s.showWAMPanel);
571+
const toggleWAMPanel = useUIStore((s) => s.toggleWAMPanel);
572+
573+
return (
574+
<button
575+
onClick={toggleWAMPanel}
576+
className={`flex h-7 items-center gap-1 rounded-md border px-2 text-[10px] font-medium transition-colors ${
577+
showWAMPanel
578+
? 'border-blue-500/40 bg-blue-500/15 text-blue-300'
579+
: 'border-white/10 bg-white/5 text-zinc-400 hover:bg-white/10 hover:text-zinc-200'
580+
}`}
581+
title="WAM Plugin Browser"
582+
aria-label="Toggle WAM plugin browser"
583+
data-testid="wam-panel-toggle"
584+
>
585+
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
586+
<rect x="2" y="3" width="20" height="18" rx="2" />
587+
<path d="M8 21V3M16 21V3" />
588+
<path d="M2 12h20" />
589+
</svg>
590+
WAM
591+
</button>
592+
);
593+
}
594+
569595
function AceStudioLink() {
570596
return (
571597
<a
@@ -950,6 +976,9 @@ export function Toolbar() {
950976
{/* VST3 Companion Status */}
951977
<CompanionStatus />
952978

979+
{/* WAM Plugins Button */}
980+
<WAMPluginButton />
981+
953982
{/* Command Palette + ACE Studio */}
954983
<div className="flex items-center gap-1 shrink-0">
955984
<button
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
/**
2+
* WAMPluginBrowser — Browse and load WAM 2.0 plugins.
3+
*
4+
* Displays the curated WAM plugin catalog with search, category filtering,
5+
* and the ability to load plugins from custom URLs.
6+
*/
7+
import { useCallback, useMemo, useState } from 'react';
8+
import { useWAMStore } from '../../store/wamStore';
9+
import type { WAMCatalogEntry } from '../../types/wam';
10+
11+
// ── Inline icons ──────────────────────────────────────────────────────────────
12+
const SearchIcon = ({ className }: { className?: string }) => (
13+
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
14+
<circle cx="11" cy="11" r="7" />
15+
<path strokeLinecap="round" d="M21 21l-4.35-4.35" />
16+
</svg>
17+
);
18+
const LinkIcon = ({ className }: { className?: string }) => (
19+
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
20+
<path strokeLinecap="round" strokeLinejoin="round" d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
21+
<path strokeLinecap="round" strokeLinejoin="round" d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
22+
</svg>
23+
);
24+
25+
type CategoryFilter = 'all' | 'instrument' | 'effect';
26+
27+
interface WAMPluginBrowserProps {
28+
/** Called when the user loads a plugin — receives the selected WAMCatalogEntry */
29+
onLoadPlugin?: (entry: WAMCatalogEntry) => void;
30+
}
31+
32+
export function WAMPluginBrowser({ onLoadPlugin }: WAMPluginBrowserProps) {
33+
const hostStatus = useWAMStore((s) => s.hostStatus);
34+
const searchCatalog = useWAMStore((s) => s.searchCatalog);
35+
36+
const [search, setSearch] = useState('');
37+
const [category, setCategory] = useState<CategoryFilter>('all');
38+
const [showUrlInput, setShowUrlInput] = useState(false);
39+
const [customUrl, setCustomUrl] = useState('');
40+
41+
const filtered = useMemo(() => {
42+
const cat = category === 'all' ? undefined : category;
43+
return searchCatalog(search, cat);
44+
}, [search, category, searchCatalog]);
45+
46+
const handleLoad = useCallback(
47+
(entry: WAMCatalogEntry) => onLoadPlugin?.(entry),
48+
[onLoadPlugin],
49+
);
50+
51+
const handleLoadCustomUrl = useCallback(() => {
52+
if (!customUrl.trim()) return;
53+
const entry: WAMCatalogEntry = {
54+
id: `custom-${Date.now()}`,
55+
name: 'Custom WAM Plugin',
56+
vendor: 'Custom',
57+
description: 'Plugin loaded from URL',
58+
category: 'effect',
59+
subcategory: 'custom',
60+
url: customUrl.trim(),
61+
tags: [],
62+
};
63+
onLoadPlugin?.(entry);
64+
setCustomUrl('');
65+
setShowUrlInput(false);
66+
}, [customUrl, onLoadPlugin]);
67+
68+
// ── Not ready state ─────────────────────────────────────
69+
if (hostStatus !== 'ready') {
70+
return (
71+
<div
72+
className="flex flex-col items-center justify-center gap-2 p-4 text-center"
73+
data-testid="wam-browser-not-ready"
74+
>
75+
<span className="text-[11px] text-zinc-400">
76+
{hostStatus === 'initializing'
77+
? 'Initializing WAM host...'
78+
: hostStatus === 'error'
79+
? 'WAM host initialization failed'
80+
: 'WAM host not initialized'}
81+
</span>
82+
{hostStatus === 'idle' && (
83+
<span className="text-[10px] text-zinc-500">
84+
Start playback to initialize the audio engine
85+
</span>
86+
)}
87+
</div>
88+
);
89+
}
90+
91+
return (
92+
<div className="flex flex-col gap-2 p-2" data-testid="wam-plugin-browser">
93+
{/* Search + URL load */}
94+
<div className="flex items-center gap-2">
95+
<div className="relative flex-1">
96+
<SearchIcon className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-zinc-500" />
97+
<input
98+
type="text"
99+
placeholder="Search WAM plugins..."
100+
value={search}
101+
onChange={(e) => setSearch(e.target.value)}
102+
aria-label="Search WAM plugins"
103+
data-testid="wam-search"
104+
className="h-7 w-full rounded-md border border-white/10 bg-white/5 pl-7 pr-2 text-xs text-white placeholder-zinc-500 outline-none focus:border-violet-500"
105+
/>
106+
</div>
107+
<button
108+
onClick={() => setShowUrlInput(!showUrlInput)}
109+
title="Load from URL"
110+
aria-label="Load plugin from URL"
111+
data-testid="wam-url-toggle"
112+
className={`flex h-7 items-center gap-1 rounded-md border px-2 text-[10px] transition-colors ${
113+
showUrlInput
114+
? 'border-violet-500/40 bg-violet-500/10 text-violet-300'
115+
: 'border-white/10 bg-white/5 text-zinc-300 hover:bg-white/10'
116+
}`}
117+
>
118+
<LinkIcon className="h-3 w-3" />
119+
URL
120+
</button>
121+
</div>
122+
123+
{/* Custom URL input */}
124+
{showUrlInput && (
125+
<div className="flex gap-1" data-testid="wam-url-input-section">
126+
<input
127+
type="url"
128+
placeholder="https://example.com/plugin/index.js"
129+
value={customUrl}
130+
onChange={(e) => setCustomUrl(e.target.value)}
131+
onKeyDown={(e) => e.key === 'Enter' && handleLoadCustomUrl()}
132+
aria-label="WAM plugin URL"
133+
data-testid="wam-url-input"
134+
className="flex-1 h-7 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-white placeholder-zinc-500 outline-none focus:border-violet-500"
135+
/>
136+
<button
137+
onClick={handleLoadCustomUrl}
138+
disabled={!customUrl.trim()}
139+
data-testid="wam-url-load-btn"
140+
className="rounded-md bg-violet-600 px-2 py-1 text-[10px] font-medium text-white hover:bg-violet-500 disabled:opacity-40"
141+
>
142+
Load
143+
</button>
144+
</div>
145+
)}
146+
147+
{/* Category tabs */}
148+
<div className="flex gap-1" role="tablist" aria-label="WAM plugin category filter">
149+
{(['all', 'instrument', 'effect'] as const).map((cat) => (
150+
<button
151+
key={cat}
152+
role="tab"
153+
aria-selected={category === cat}
154+
onClick={() => setCategory(cat)}
155+
data-testid={`wam-category-tab-${cat}`}
156+
className={`rounded-md px-2 py-0.5 text-[10px] font-medium capitalize transition-colors ${
157+
category === cat
158+
? 'bg-violet-600 text-white'
159+
: 'bg-white/5 text-zinc-400 hover:bg-white/10'
160+
}`}
161+
>
162+
{cat === 'all' ? 'All' : cat === 'instrument' ? 'Instruments' : 'Effects'}
163+
</button>
164+
))}
165+
</div>
166+
167+
{/* Plugin list */}
168+
{filtered.length === 0 ? (
169+
<div className="py-4 text-center text-[11px] text-zinc-500" data-testid="wam-browser-empty">
170+
No WAM plugins found.
171+
</div>
172+
) : (
173+
<div className="flex flex-col overflow-y-auto max-h-[300px]" data-testid="wam-plugin-list">
174+
{filtered.map((entry) => (
175+
<WAMPluginRow key={entry.id} entry={entry} onLoad={handleLoad} />
176+
))}
177+
</div>
178+
)}
179+
</div>
180+
);
181+
}
182+
183+
// ── Single plugin row ──────────────────────────────────────────────────────────
184+
185+
function WAMPluginRow({
186+
entry,
187+
onLoad,
188+
}: {
189+
entry: WAMCatalogEntry;
190+
onLoad: (entry: WAMCatalogEntry) => void;
191+
}) {
192+
return (
193+
<div
194+
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-xs text-zinc-300 hover:bg-white/5"
195+
data-testid="wam-plugin-row"
196+
data-plugin-id={entry.id}
197+
title={`${entry.name} by ${entry.vendor}${entry.description}`}
198+
>
199+
{/* Category indicator */}
200+
<span
201+
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
202+
entry.category === 'instrument' ? 'bg-emerald-400' : 'bg-blue-400'
203+
}`}
204+
/>
205+
<span className="flex-1 truncate">{entry.name}</span>
206+
<span className="shrink-0 text-[10px] text-zinc-500 max-w-[70px] truncate">
207+
{entry.subcategory}
208+
</span>
209+
<button
210+
onClick={(e) => {
211+
e.stopPropagation();
212+
onLoad(entry);
213+
}}
214+
className="shrink-0 rounded bg-violet-600/60 px-1.5 py-0.5 text-[9px] font-medium text-white hover:bg-violet-500"
215+
data-testid="wam-load-btn"
216+
title={`Load ${entry.name}`}
217+
aria-label={`Load ${entry.name}`}
218+
>
219+
Load
220+
</button>
221+
</div>
222+
);
223+
}

0 commit comments

Comments
 (0)