2<html lang="en">
3 <head>
4 <meta charset="utf-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1" />
6 <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><rect width=%22100%22 height=%22100%22 fill=%22%23f2f2f2%22/><rect x=%2210%22 y=%2258%22 width=%2280%22 height=%2224%22 rx=%224%22 fill=%22%23a07040%22/><circle cx=%2250%22 cy=%2270%22 r=%227%22 fill=%22%23f2f2f2%22/><path d=%22M10 48 Q 50 28 90 48%22 stroke=%22%23b40426%22 stroke-width=%224%22 fill=%22none%22/><path d=%22M10 48 L90 48%22 stroke=%22%233b4cc0%22 stroke-width=%222%22/></svg>" />
7 <title>dulcimer — a plucked string and its box, in wave equations</title>
8 <style>
9 :root {
10 --bg: #ffffff;
11 --ink: #1f2328;
12 --ink-2: #57606a;
13 --line: #d0d7de;
14 --accent: #0969da;
15 --panel-bg: #f4f6f8;
16 --tok-com: #6e7781;
17 --tok-str: #0a3069;
18 --tok-num: #0550ae;
19 --tok-kw: #cf222e;
20 --tok-ext: #8250df;
21 color-scheme: light dark;
22 }
23 @media (prefers-color-scheme: dark) {
24 :root {
25 --bg: #14171a;
26 --ink: #e6e9ec;
27 --ink-2: #9aa4af;
28 --line: #333b44;
29 --accent: #58a6ff;
30 --panel-bg: #14161c;
31 --tok-com: #8b949e;
32 --tok-str: #a5d6ff;
33 --tok-num: #79c0ff;
34 --tok-kw: #ff7b72;
35 --tok-ext: #d2a8ff;
36 }
37 }
38 body {
39 margin: 0;
40 background: var(--bg);
41 color: var(--ink);
42 font: 15px/1.5 system-ui, -apple-system, sans-serif;
43 }
44 main { max-width: 1100px; margin: 0 auto; padding: 20px 16px 48px; }
45 h1 { font-size: 20px; margin: 0 0 2px; }
46 .sub { color: var(--ink-2); margin: 0 0 12px; font-size: 13px; }
47 .sub a { color: var(--accent); }
48 .controls {
49 display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center;
50 padding: 5px 0;
51 }
52 .controls label { color: var(--ink-2); font-size: 13px; white-space: nowrap; }
53 select, input[type="number"], button {
54 font: inherit; font-size: 13px;
55 color: var(--ink); background: var(--bg);
56 border: 1px solid var(--line); border-radius: 6px;
57 padding: 4px 8px;
58 }
59 input[type="range"] { width: 8em; vertical-align: middle; accent-color: var(--accent); }
60 button { cursor: pointer; }
61 button:hover { border-color: var(--accent); }
62 button:disabled { cursor: default; opacity: 0.5; }
63 button.primary { border-color: var(--accent); color: var(--accent); font-weight: 600; min-width: 5.5em; }
64 .sliders {
65 display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
66 gap: 2px 16px; padding: 4px 0;
67 }
68 #micbar { gap: 8px 16px; }
69 #micparams { display: flex; flex-wrap: wrap; gap: 0 16px; padding: 0; }
70 #recinfo { margin-top: 0; }
71 .slider { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--ink-2); }
72 .slider > span:first-child { flex: 0 0 9.5em; text-align: right; }
73 .slider > output { flex: 0 0 4.5em; font-variant-numeric: tabular-nums; color: var(--ink); }
74 .group-title {
75 font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em;
76 color: var(--ink-2); margin: 10px 0 0;
77 }
78 progress { width: 10em; height: 0.9em; }
79 #stringbox {
80 margin-top: 12px; border: 1px solid var(--line); border-radius: 8px;
81 background: var(--panel-bg); position: relative;
82 }
83 #stringplot { width: 100%; height: 92px; display: block; }
84 #stringlabel {
85 position: absolute; top: 4px; left: 8px; font-size: 11px;
86 color: var(--ink-2); font-variant-numeric: tabular-nums;
87 }
88 #stage {
89 display: flex; gap: 14px; margin-top: 12px; align-items: stretch;
90 border: 1px solid var(--line); border-radius: 8px; overflow: hidden;
91 }
92 .canvas-box {
93 flex: 1; aspect-ratio: 16 / 10; max-height: 70vh; position: relative;
94 background: #0b0e12;
95 }
96 #view { width: 100%; height: 100%; display: block; cursor: grab; touch-action: none; }
97 #overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
98 .colorbar {
99 display: flex; flex-direction: column; align-items: center; justify-content: center;
100 gap: 4px; padding: 8px 4px; background: var(--panel-bg);
101 width: 60px; flex: none; box-sizing: border-box;
102 }
103 .colorbar canvas { border: 1px solid var(--line); border-radius: 2px; }
104 .colorbar-label { font-size: 11px; color: var(--ink-2); font-variant-numeric: tabular-nums; }
105 .readout { font-variant-numeric: tabular-nums; color: var(--ink); }
106 .warn { color: #b35900; font-weight: 600; }
107 .stats { margin-top: 8px; font-size: 13px; color: var(--ink-2); font-variant-numeric: tabular-nums; }
108 .stats b { color: var(--ink); font-weight: 600; }
109 #err {
110 color: #b35900; white-space: pre-wrap; font-size: 13px;
111 font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
112 }
113 .editor {
114 margin-top: 12px; border: 1px solid var(--line); border-radius: 8px;
115 overflow: hidden;
116 }
117 .editor-head {
118 display: flex; gap: 10px; align-items: center; justify-content: space-between;
119 padding: 6px 10px; font-size: 12px; color: var(--ink-2);
120 background: var(--panel-bg); border-bottom: 1px solid var(--line);
121 }
122 .editor-head button { padding: 2px 10px; font-size: 12px; }
123 /* The fixed height lives on the row, not on either child: sizing the row
124 makes both children stretch to one shared pixel height regardless of
125 either's font size. */
126 .editor-body { display: flex; align-items: stretch; height: 32em; }
127 .editor-code { position: relative; flex: 1 1 62%; min-width: 0; }
128 /* The overlay and the textarea must agree on every metric that affects
129 where a character lands. Keep these two rules together. */
130 .editor-code > pre,
131 .editor-code > textarea {
132 margin: 0; padding: 10px 12px; border: 0;
133 box-sizing: border-box; width: 100%; height: 100%;
134 font: 12.5px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
135 tab-size: 2;
136 white-space: pre; overflow-wrap: normal;
137 }
138 #highlight {
139 position: absolute; inset: 0; overflow: hidden;
140 pointer-events: none; background: var(--bg); color: var(--ink);
141 }
142 #source {
143 position: relative; z-index: 1; display: block;
144 resize: none; overflow: auto;
145 background: transparent; color: transparent; caret-color: var(--ink);
146 }
147 #source:focus { outline: none; }
148 /* Transparent text means the selection must be see-through, or selected
149 code would be invisible. */
150 #source::selection { background: color-mix(in srgb, var(--accent) 28%, transparent); }
151 #compiled {
152 flex: 1 1 38%; min-width: 0; margin: 0; padding: 10px 12px; overflow: auto;
153 border-left: 1px solid var(--line); background: var(--panel-bg);
154 font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
155 color: var(--ink-2); white-space: pre;
156 }
157 @media (max-width: 860px) {
158 .editor-body { flex-direction: column; height: auto; }
159 .editor-code { flex: none; height: 26em; }
160 #compiled { border-left: 0; border-top: 1px solid var(--line); max-height: 12em; }
161 }
162 .tok-com { color: var(--tok-com); }
163 .tok-str { color: var(--tok-str); }
164 .tok-num { color: var(--tok-num); }
165 .tok-kw { color: var(--tok-kw); font-weight: 600; }
166 .tok-ext { color: var(--tok-ext); }
167 </style>
168 </head>
169 <body>
170 <main>
171 <h1>dulcimer</h1>
172 <p class="sub">
173 One plucked string and the box it sounds over, simulated as two coupled
174 wave equations — a stiff damped string, and 3D acoustics around a rigid
175 body — both written as the MATLAB below, run in your browser by
176 <a href="https://numbl.org">numbl</a> and compiled to WebGPU compute
177 shaders. Pluck it, watch the sound, put a microphone in the air, and
178 listen.
179 </p>
180 <p class="sub" id="domaininfo"></p>
182 <div class="controls">
183 <label title="Air grid points along the string; across and up get half each. Changing it recompiles.">grid
184 <select id="gridsize">
185 <option value="64">64 (draft)</option>
186 <option value="96">96</option>
187 <option value="128" selected>128</option>
188 <option value="160">160</option>
189 <option value="192">192</option>
190 </select>
191 </label>
192 <label title="String length. Changing it recompiles.">string length
193 <select id="stringlen">
194 <option value="0.45">0.45 m</option>
195 <option value="0.6" selected>0.6 m</option>
196 <option value="0.72">0.72 m</option>
197 </select>
198 </label>
199 <button id="runpause" class="primary">Run</button>
200 <button id="pluck" title="Draw the string back into its pluck and release it, clearing the recording">Pluck</button>
201 <label title="Timesteps taken between display frames while watching live. Below 1×, one step is taken every few frames.">speed
202 <select id="spf">
203 <option value="0.25">¼×</option>
204 <option value="0.5">½×</option>
205 <option value="1">1×</option>
206 <option value="4">4×</option>
207 <option value="16" selected>16×</option>
208 <option value="64">64×</option>
209 <option value="256">256×</option>
210 </select>
211 </label>
212 <span style="flex:1"></span>
213 <label>render
214 <select id="renderdur">
215 <option value="1">1 s</option>
216 <option value="2" selected>2 s</option>
217 <option value="3">3 s</option>
218 <option value="5">5 s</option>
219 </select>
220 </label>
221 <button id="rendernote" title="Pluck, then run the solver flat out with no display until this much of the note is recorded, and play it">Render note</button>
222 <progress id="renderprog" value="0" max="1" hidden></progress>
223 </div>
225 <p class="group-title">string</p>
226 <div class="sliders" id="params"></div>
227 <p class="group-title" id="scene-title">body</p>
228 <div class="sliders" id="sceneparams"></div>
230 <p class="group-title">microphone</p>
231 <div class="controls" id="micbar">
232 <div class="sliders" id="micparams"></div>
233 <button id="listen" title="Play what the microphone has recorded since the pluck, in real time at its real pitch">Listen</button>
234 <button id="download" title="Save the recording as a 48 kHz .wav">Save .wav</button>
235 <span class="stats" id="recinfo"></span>
236 </div>
238 <div class="controls">
239 <label>colormap
240 <select id="colormap"></select>
241 </label>
242 <label title="Pressure the colour scale saturates at. Auto follows the field.">scale
243 <select id="scalemode">
244 <option value="auto" selected>auto</option>
245 <option value="fixed">hold</option>
246 </select>
247 </label>
248 <label>opacity
249 <input type="range" id="opacity" min="0.2" max="6" step="0.1" value="2" />
250 </label>
251 <label>contrast
252 <input type="range" id="contrast" min="0.5" max="3" step="0.1" value="1.6" />
253 </label>
254 <label title="Ray-march samples per pixel ray">quality
255 <select id="quality">
256 <option value="96">96</option>
257 <option value="192" selected>192</option>
258 <option value="320">320</option>
259 </select>
260 </label>
261 <label title="Cut the picture open along the string: everything on the far side of this y-plane is hidden">clip
262 <input type="range" id="clipy" min="-0.5" max="0.5" step="0.01" value="0.5" />
263 </label>
264 <label title="How many times larger than life the string's displacement is drawn in the 3D view">string ×
265 <input type="range" id="exaggerate" min="1" max="100" step="1" value="25" />
266 </label>
267 </div>
268 <div class="controls">
269 <label><input type="checkbox" id="showfield" checked /> sound</label>
270 <label><input type="checkbox" id="showbody" checked /> body</label>
271 <label><input type="checkbox" id="showstring" checked /> string</label>
272 <label><input type="checkbox" id="showwire" checked /> outline</label>
273 <label><input type="checkbox" id="showmic" checked /> mic</label>
274 </div>
276 <div id="stringbox">
277 <canvas id="stringplot"></canvas>
278 <span id="stringlabel"></span>
279 </div>
281 <div id="stage">
282 <div class="canvas-box">
283 <canvas id="view"></canvas>
284 <canvas id="overlay"></canvas>
285 </div>
286 <div id="colorbar"></div>
287 </div>
288 <p class="stats" id="stats"></p>
289 <p id="err"></p>
291 <div class="editor">
292 <div class="editor-head">
293 <span>
294 <select id="editor-file" aria-label="file to edit">
295 <option value="model">model (.m) — the two solvers</option>
296 <option value="scene">scene (.m) — the body</option>
297 </select>
298 <span id="editor-title"></span>
299 </span>
300 <span>
301 <button id="recompile" type="button">Run edits</button>
302 <button id="revert" type="button">Revert</button>
303 </span>
304 </div>
305 <div class="editor-body">
306 <div class="editor-code">
307 <pre id="highlight" aria-hidden="true"></pre>
308 <textarea
309 id="source"
310 spellcheck="false"
311 autocomplete="off"
312 autocapitalize="off"
313 aria-label="source (MATLAB)"
314 ></textarea>
315 </div>
316 <pre id="compiled"></pre>
317 </div>
318 </div>
319 </main>
320 <script type="module" src="/src/main.ts"></script>
321 </body>
322</html>