4dff647Bead-sheet cymbal: 2D FPUT plate with bending and dome, chaos scan, web appJeremy Magland 1<meta charset="utf-8">
2<title>Bead Sheet Cymbal</title>
3<link rel="preconnect" href="https://fonts.googleapis.com">
4<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
5<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@75..100,400..800&family=IBM+Plex+Mono:wght@400;500&display=swap">
6<style>
7:root {
8 --paper: #eef2f5;
9 --panel: #f8fafb;
10 --ink: #172334;
11 --muted: #5b6b7d;
12 --rule: #cbd5df;
13 --grid: #dde4eb;
14 --x: #2563a6;
15 --y: #a23b72;
16 --chaos: #e8b931;
17 --brass: #b07a22;
18 --up: #c2410c;
19 --down: #2563a6;
20 --chaos-wash: rgba(232, 185, 49, 0.18);
21 --faint: rgba(23, 35, 52, 0.16);
22 --focus: #2563a6;
23 --sans: "Archivo", "Helvetica Neue", Arial, sans-serif;
24 --mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
25}
26@media (prefers-color-scheme: dark) {
27 :root:not([data-theme="light"]) {
28 color-scheme: dark;
29 --paper: #0f161f; --panel: #16202c; --ink: #e3e9f0; --muted: #93a3b5; --rule: #2a3747; --grid: #1f2a37;
30 --x: #6fa8e8; --y: #d57bae; --chaos: #e8b931; --brass: #d8a64f; --up: #f08a5d; --down: #6fa8e8;
31 --chaos-wash: rgba(232, 185, 49, 0.14); --faint: rgba(227, 233, 240, 0.16); --focus: #6fa8e8;
32 }
33}
34:root[data-theme="dark"] {
35 color-scheme: dark;
36 --paper: #0f161f; --panel: #16202c; --ink: #e3e9f0; --muted: #93a3b5; --rule: #2a3747; --grid: #1f2a37;
37 --x: #6fa8e8; --y: #d57bae; --chaos: #e8b931; --brass: #d8a64f; --up: #f08a5d; --down: #6fa8e8;
38 --chaos-wash: rgba(232, 185, 49, 0.14); --faint: rgba(227, 233, 240, 0.16); --focus: #6fa8e8;
39}
40* { box-sizing: border-box; }
41body { background: var(--paper); color: var(--ink); font-family: var(--sans); font-size: 15px; line-height: 1.5; padding-inline: 16px; padding-block: 20px 48px; }
42.wrap { max-width: 1280px; margin: 0 auto; display: grid; gap: 20px; }
43header { display: grid; gap: 6px; }
44h1 { font-size: clamp(26px, 4vw, 38px); font-weight: 750; font-stretch: 78%; letter-spacing: -0.01em; line-height: 1.05; margin: 0; text-wrap: balance; }
45h1 .eq { font-family: var(--mono); font-weight: 400; font-size: 0.42em; color: var(--muted); letter-spacing: 0; display: block; margin-top: 8px; font-stretch: 100%; }
46.lede { max-width: 78ch; color: var(--muted); margin: 0; }
47.layout { display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 20px; align-items: start; }
48@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
49aside { background: var(--panel); border: 1px solid var(--rule); border-radius: 6px; padding: 16px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; position: sticky; top: calc(env(safe-area-inset-top, 0px) + 12px); }
50@media (max-width: 900px) { aside { position: static; } }
51.label { font-size: 11px; font-weight: 650; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }
52.presets { display: flex; flex-wrap: wrap; gap: 6px; }
53.chip { font: inherit; font-size: 13px; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--rule); background: var(--paper); color: var(--ink); cursor: pointer; }
54.chip:hover { border-color: var(--muted); }
55.chip[aria-pressed="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }
56.field { display: grid; gap: 4px; }
57.field .row { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
58.field label { font-size: 14px; font-weight: 550; }
59.val { font-family: var(--mono); font-size: 13px; font-variant-numeric: tabular-nums; color: var(--ink); white-space: nowrap; }
60.hint { font-size: 12px; color: var(--muted); margin: 0; }
61input[type="range"] { width: 100%; accent-color: var(--x); }
62input[type="range"]:disabled { opacity: 0.4; }
63.two { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
64.player { display: grid; gap: 8px; }
65.btns { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
66.btn { font: inherit; font-weight: 600; font-size: 14px; padding: 9px 10px; border-radius: 6px; border: 1px solid var(--rule); background: var(--paper); color: var(--ink); cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px; }
67.btn:hover { border-color: var(--muted); }
68.btn .sw { width: 10px; height: 10px; border-radius: 2px; flex: none; }
69.btn[aria-pressed="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }
70button:focus-visible, input:focus-visible, canvas:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
71.stats { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 13px; margin: 0; }
72.stats dt { color: var(--muted); }
73.stats dd { margin: 0; font-family: var(--mono); font-variant-numeric: tabular-nums; text-align: right; }
74.status { font-size: 12px; color: var(--muted); min-height: 1.5em; font-family: var(--mono); }
75main { display: grid; gap: 16px; min-width: 0; }
76.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
77@media (max-width: 640px) { .pair { grid-template-columns: 1fr; } }
78figure { margin: 0; display: grid; gap: 6px; min-width: 0; }
79figcaption { display: grid; gap: 2px; }
80figcaption b { font-weight: 650; font-size: 15px; }
81figcaption span { font-size: 13px; color: var(--muted); max-width: 82ch; }
82.plot { position: relative; width: 100%; background: var(--panel); border: 1px solid var(--rule); border-radius: 6px; }
83.plot canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
84.sheetwrap { display: grid; grid-template-columns: minmax(0, 360px) minmax(0, 1fr); gap: 16px; align-items: start; }
85@media (max-width: 760px) { .sheetwrap { grid-template-columns: 1fr; } }
86.sheet { aspect-ratio: 1 / 1; max-width: 100%; cursor: crosshair; }
87.wide { aspect-ratio: 3.2 / 1; min-height: 170px; }
88.half { aspect-ratio: 1.6 / 1; min-height: 170px; }
89.short { aspect-ratio: 4.6 / 1; min-height: 140px; }
90.setuptext { display: grid; gap: 10px; font-size: 14px; }
91.setuptext p { margin: 0; max-width: 70ch; }
92.setuptext .btn { justify-self: start; }
93.strikes { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
94.sk { font: inherit; font-family: var(--mono); font-size: 13px; min-width: 38px; padding: 4px 8px; border-radius: 6px; border: 1px solid var(--rule); background: var(--paper); color: var(--ink); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; justify-content: center; }
95.sk .sw { width: 8px; height: 8px; border-radius: 2px; }
96.sk[aria-pressed="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }
97.sk:disabled { opacity: 0.4; cursor: default; }
98.strikes .btn { padding: 4px 12px; font-size: 13px; margin-left: auto; }
99.legend { display: flex; flex-wrap: wrap; gap: 14px; font-size: 12px; color: var(--muted); }
100.legend i { display: inline-block; width: 18px; height: 3px; vertical-align: middle; margin-right: 6px; border-radius: 2px; }
101footer { color: var(--muted); font-size: 13px; max-width: 82ch; }
102footer p { margin: 0 0 8px; }
103/* keep the control column inside its panel whatever the readouts say */
104aside > * { min-width: 0; }
105aside .field .row { flex-wrap: wrap; }
106aside .val { white-space: normal; text-align: right; overflow-wrap: anywhere; }
107</style>
109<div class="wrap">
110 <header>
111 <h1>Bead Sheet Cymbal
112 <span class="eq">a square sheet of beads: stretching links + bending + dome, pinned edges, struck once</span>
113 </h1>
114 <p class="lede">The FPUT chain made two-dimensional. Beads on a square sheet move up and down; the links between neighbours stiffen when stretched (which couples the modes), the sheet resists bending (which gives it a plate's spectrum, about 550 modes between 100 Hz and 17 kHz, denser toward the top), and it can be built as a dome. Unlike a wire, a sheet has more and more modes per octave as the pitch rises, so a hard strike can spread its energy into a dense high-frequency wash, as a cymbal or gong does.</p>
115 </header>
117 <div class="layout">
118 <aside aria-label="Controls">
119 <div class="player">
120 <div class="label">Listen</div>
121 <div class="btns">
122 <button class="btn" id="playA" type="button"><span class="sw" style="background: var(--x)"></span>Strike A</button>
123 <button class="btn" id="playB" type="button"><span class="sw" style="background: var(--y)"></span>Strike B</button>
124 <button class="btn" id="playAB" type="button">A, then B</button>
125 <button class="btn" id="stop" type="button">Stop</button>
126 </div>
127 <div class="status" id="status">Computing…</div>
128 </div>
130 <div class="field">
131 <div class="row"><label>Listen at</label><span class="val" id="lisv"></span></div>
132 <div class="presets" role="group" aria-label="Listener">
133 <button class="chip" type="button" id="lisOne" aria-pressed="true">One point</button>
134 <button class="chip" type="button" id="lisSum" aria-pressed="false">Whole sheet (sum)</button>
135 </div>
136 <div class="two">
137 <input id="lx" type="range" min="0.05" max="0.95" step="0.01" aria-label="Listener across">
138 <input id="ly" type="range" min="0.05" max="0.95" step="0.01" aria-label="Listener down">
139 </div>
140 <p class="hint">Shift-click the sheet to move the listener. The sum is roughly what a distant listener hears.</p>
141 </div>
143 <div class="field">
144 <div class="label">Presets</div>
145 <div class="presets">
146 <button class="chip" type="button" data-preset="soft">Soft</button>
147 <button class="chip" type="button" data-preset="gong">Gong</button>
148 <button class="chip" type="button" data-preset="crash">Crash</button>
149 <button class="chip" type="button" data-preset="flat">Flat, no dome</button>
150 <button class="chip" type="button" data-preset="cgong">Chaotic gong</button>
151 <button class="chip" type="button" data-preset="ccrash">Chaotic crash</button>
152 </div>
153 </div>
155 <div class="field">
156 <div class="row"><label for="E0">Strike energy E</label><span class="val" id="E0v"></span></div>
157 <input id="E0" type="range" min="0" max="1000" step="1">
158 <p class="hint">For hits that differ from one another (chaos), the strike must be hard and the high modes must ring long: with bending 8, E ≈ 30k and high modes ringing 2 s or more; with bending 25, E ≈ 100k. A dome helps. See the two Chaotic presets.</p>
159 </div>
160 <div class="field">
161 <div class="row"><label for="Kb">Bending stiffness</label><span class="val" id="Kbv"></span></div>
162 <input id="Kb" type="range" min="0" max="40" step="0.5">
163 <p class="hint">More bending spreads the modes higher (top shown under Pitch) but takes a harder strike to set the nonlinearity going.</p>
164 </div>
165 <div class="field">
166 <div class="row"><label for="Hd">Dome height</label><span class="val" id="Hdv"></span></div>
167 <input id="Hd" type="range" min="0" max="30" step="0.5">
168 </div>
169 <div class="field">
170 <div class="row"><label for="N">Beads per side</label><span class="val" id="Nv"></span></div>
171 <input id="N" type="range" min="12" max="32" step="1">
172 </div>
173 <div class="field">
174 <div class="row"><label>Strike position</label><span class="val" id="sposv"></span></div>
175 <div class="two">
176 <input id="sx" type="range" min="0.08" max="0.92" step="0.01" aria-label="Strike across">
177 <input id="sy" type="range" min="0.08" max="0.92" step="0.01" aria-label="Strike down">
178 </div>
179 <p class="hint">Or click the sheet.</p>
180 </div>
181 <div class="field">
182 <div class="row"><label for="rad">Strike radius</label><span class="val" id="radv"></span></div>
183 <input id="rad" type="range" min="0.6" max="5" step="0.1">
184 </div>
185 <div class="field">
186 <div class="row"><label for="dl">Difference between strikes</label><span class="val" id="dlv"></span></div>
187 <input id="dl" type="range" min="-14" max="-2" step="0.5">
188 <p class="hint">1e-6 shows chaos only. Real hits differ by about 1e-2, which makes them sound different even without chaos, through pitch shifts that depend on the amplitude.</p>
189 </div>
190 <div class="field">
191 <div class="row"><label for="tlo">Ring time, lowest mode</label><span class="val" id="tlov"></span></div>
192 <input id="tlo" type="range" min="0.5" max="10" step="0.1">
193 </div>
194 <div class="field">
195 <div class="row"><label for="thi">Ring time, highest mode</label><span class="val" id="thiv"></span></div>
196 <input id="thi" type="range" min="0.05" max="5" step="0.05">
197 </div>
198 <div class="field">
199 <div class="row"><label for="f1">Pitch of the lowest mode</label><span class="val" id="f1v"></span></div>
200 <input id="f1" type="range" min="40" max="300" step="1">
201 <p class="hint" id="f1note" hidden>Held lower than the slider so the highest mode stays under 20 kHz.</p>
202 </div>
203 <div class="field">
204 <div class="row"><label for="dur">Length</label><span class="val" id="durv"></span></div>
205 <input id="dur" type="range" min="1" max="4" step="0.5">
206 </div>
208 <dl class="stats" aria-live="polite">
209 <dt>Energy above 4 kHz, peak</dt><dd id="sBloom">–</dd>
210 <dt>… reached at</dt><dd id="sBloomT">–</dd>
211 <dt>Chaotic phase ends</dt><dd id="sChaos">–</dd>
212 <dt>Peak separation rate</dt><dd id="sLam">–</dd>
213 <dt>Final spectra, 8 strikes</dt><dd id="sSpread">–</dd>
214 </dl>
215 </aside>
217 <main>
218 <figure>
219 <figcaption><b>Physical setup</b><span>The sheet seen from above. Each square is a bead; its color is its height (warm up, cool down), drawn magnified. The edges are pinned. Click to move the strike (gold); shift-click to move the listener (ring).</span></figcaption>
220 <div class="sheetwrap">
221 <div class="plot sheet"><canvas id="cSheet" tabindex="0" aria-label="The sheet; click to move the strike, shift-click to move the listener"></canvas></div>
222 <div class="setuptext">
223 <p>Each bead is tied to its four neighbours by links that pull harder as they stretch: a height difference D between neighbours stretches the link by about D²/2, so its stiffness grows with D². This is the FPUT chain's nonlinearity, now in two directions, and it plays the role of a cymbal's midplane stretching, passing energy from mode to mode.</p>
224 <p>The sheet also resists bending, so short ripples vibrate much faster than long ones, as in a metal plate. Built as a dome, it stiffens and gains the three-wave coupling of curved shells. The strike gives a patch of beads a sudden upward velocity; damping is friction plus dashpots between neighbours, strongest for the shortest ripples.</p>
225 <button class="btn" id="slow" type="button" aria-pressed="false">Slow motion</button>
226 </div>
227 </div>
228 </figure>
230 <figure>
231 <figcaption><b>Where the energy is</b><span>Share of strike A's energy in each frequency band (modes grouped by pitch, log scale) over time. A cascade shows as energy climbing toward the top.</span></figcaption>
232 <div class="plot wide"><canvas id="cModes"></canvas><canvas id="cModesO"></canvas></div>
233 </figure>
235 <div class="pair">
236 <figure>
237 <figcaption><b>How high it reaches</b><span>Share of each strike's energy above 4 kHz (solid) and above 1 kHz (thin), for all 8 strikes. Shaded: the chaotic phase of strike A.</span></figcaption>
238 <div class="plot half"><canvas id="cHi"></canvas><canvas id="cHiO"></canvas></div>
239 </figure>
240 <figure>
241 <figcaption><b>The spectrum it rings with</b><span>Each strike's final share of energy per frequency band.</span></figcaption>
242 <div class="plot half"><canvas id="cFinal"></canvas></div>
243 </figure>
244 </div>
245 <div class="legend"><span><i style="background: var(--x)"></i>Strike A</span><span><i style="background: var(--y)"></i>Strike B</span><span><i style="background: var(--faint)"></i>6 more strikes</span></div>
247 <figure>
248 <figcaption><b>How fast nearby strikes separate</b><span>Local Lyapunov exponent of strike A (per second, 50 ms windows). The dashed line is the energy in dB.</span></figcaption>
249 <div class="plot short"><canvas id="cLam"></canvas><canvas id="cLamO"></canvas></div>
250 </figure>
252 <figure>
253 <figcaption><b>What you hear</b><span>Spectrogram of the selected strike at the listener. Click a strike, or use the ← and → keys, to flip between them; the color scale is shared, so differences in level are real.</span></figcaption>
254 <div class="strikes" role="group" aria-label="Choose a strike" id="strikeSel"></div>
255 <div class="plot wide"><canvas id="cSpec"></canvas><canvas id="cSpecO"></canvas></div>
256 </figure>
258 <footer>
259 <p>Computed in your browser, the 8 strikes in parallel: leapfrog time stepping with the damping applied each step. Mode energies use the flat sheet's modes (products of sines), so with a dome they are approximate. Time is scaled so that the lowest mode sounds at the chosen pitch, held low enough that the highest stays below 20 kHz. The links pull only along the vertical; a real plate's midplane can also slide sideways and partly relax the stretching, so this sheet's nonlinearity is stiffer than a plate's, the same simplification FPUT makes.</p>
260 </footer>
261 </main>
262 </div>
263</div>
265<script id="worker-src" type="text/plain">
266function setupSheet(p) {
267 const N = p.N, M = N + 1;
268 const s = new Float64Array(N);
269 for (let k = 0; k < N; k++) s[k] = 4 * Math.sin((k + 1) * Math.PI / (2 * M)) ** 2;
270 const om = new Float64Array(N * N);
271 for (let a = 0; a < N; a++) for (let b = 0; b < N; b++) { const lam = s[a] + s[b]; om[a * N + b] = Math.sqrt(p.T * lam + p.Kb * lam * lam); }
272 const S = new Float64Array(N * N), c = Math.sqrt(2 / M);
273 for (let i = 0; i < N; i++) for (let k = 0; k < N; k++) S[i * N + k] = c * Math.sin((i + 1) * (k + 1) * Math.PI / M);
274 // rest slopes of the links: z = 16 Hd x (1 - x) y (1 - y) on the padded grid
275 const z = (i, j) => 16 * p.Hd * (i / M) * (1 - i / M) * (j / M) * (1 - j / M);
276 const zh = new Float64Array(N * M), zv = new Float64Array(M * N);
277 for (let i = 0; i < N; i++) for (let j = 0; j < M; j++) zh[i * M + j] = z(i + 1, j + 1) - z(i + 1, j);
278 for (let i = 0; i < M; i++) for (let j = 0; j < N; j++) zv[i * N + j] = z(i + 1, j + 1) - z(i, j + 1);
279 return { om, S, zh, zv };
280}
282function strikeRun(p, sh, scale, opts) {
283 // Arrays are padded with a ring of zeros (the pinned boundary), so the stencils need no edge tests: node (i, j)
284 // sits at (i + 1) * P + j + 1. The steps are fused into three passes per substep; the arithmetic, and so the
285 // result, is the same as the plain kick, drift, force, kick, damp sequence.
286 const N = p.N, M = N + 1, P = N + 2, NN = N * N, PP = P * P, T = p.T, beta = p.beta, Kb = p.Kb, zh = sh.zh, zv = sh.zv;
287 const c0 = p.c0, c1 = p.c1, tan = !!opts.tangent;
288 let w = new Float64Array(PP), v = new Float64Array(PP), vb = new Float64Array(PP);
289 const f = new Float64Array(PP), L1 = new Float64Array(PP);
290 let dw = null, dv = null, dvb = null, g = null, dL1 = null, ch = null, cv = null;
291 const fvA = new Float64Array(N), fvB = new Float64Array(N), gvA = new Float64Array(N), gvB = new Float64Array(N);
292 let n2 = 0;
293 for (let i = 0; i < N; i++) for (let j = 0; j < N; j++) {
294 const q = Math.exp(-0.5 * ((i + 1 - p.ci) ** 2 + (j + 1 - p.cj) ** 2) / (p.rad * p.rad));
295 v[(i + 1) * P + j + 1] = q; n2 += q * q;
296 }
297 const amp = Math.sqrt(2 * p.E0 / n2) * scale;
298 for (let k = 0; k < PP; k++) v[k] *= amp;
299 if (tan) {
300 dw = new Float64Array(PP); dv = new Float64Array(PP); dvb = new Float64Array(PP); g = new Float64Array(PP); dL1 = new Float64Array(PP);
301 ch = new Float64Array(N * M); cv = new Float64Array(M * N);
302 for (let i = 0; i < N; i++) for (let j = 0; j < N; j++) { const k = i * N + j, q = (i + 1) * P + j + 1; dw[q] = Math.sin(1.7 * k + 0.3); dv[q] = Math.cos(2.3 * k + 0.1); }
303 normalize(dw, dv);
304 }
306 function lapInto(u, out) {
307 for (let i = 1; i <= N; i++) { const r = i * P; for (let q = r + 1; q <= r + N; q++) out[q] = u[q - P] + u[q + P] + u[q - 1] + u[q + 1] - 4 * u[q]; }
308 }
309 // force on u, written to out, and the half kick vv += hh * out
310 function forceKick(u, out, vv, hh) {
311 for (let j = 0; j < N; j++) {
312 const d = u[P + j + 1], zz = zv[j], e = zz * d + 0.5 * d * d;
313 fvA[j] = T * d + 2 * beta * e * (zz + d);
314 }
315 lapInto(u, L1);
316 for (let i = 0; i < N; i++) {
317 const r = (i + 1) * P, rh = i * M, rv = (i + 1) * N;
318 for (let j = 0; j < N; j++) {
319 const d = u[r + P + j + 1] - u[r + j + 1], zz = zv[rv + j], e = zz * d + 0.5 * d * d;
320 fvB[j] = T * d + 2 * beta * e * (zz + d);
321 }
322 let d = u[r + 1], zz = zh[rh], e = zz * d + 0.5 * d * d, hp = T * d + 2 * beta * e * (zz + d);
323 for (let j = 0; j < N; j++) {
324 const q = r + j + 1;
325 d = u[q + 1] - u[q]; zz = zh[rh + j + 1]; e = zz * d + 0.5 * d * d;
326 const hn = T * d + 2 * beta * e * (zz + d);
327 const fo = hn - hp + fvB[j] - fvA[j] - Kb * (L1[q - P] + L1[q + P] + L1[q - 1] + L1[q + 1] - 4 * L1[q]);
328 out[q] = fo; vv[q] += hh * fo; hp = hn;
329 }
330 fvA.set(fvB);
331 }
332 }
333 // the same, also keeping the link stiffnesses for tforceKick
334 function forceKickKeep(u, out, vv, hh) {
335 for (let j = 0; j < N; j++) {
336 const d = u[P + j + 1], zz = zv[j], e = zz * d + 0.5 * d * d;
337 fvA[j] = T * d + 2 * beta * e * (zz + d);
338 { const s = zz + d; cv[j] = T + 2 * beta * (s * s + zz * d + 0.5 * d * d); }
339 }
340 lapInto(u, L1);
341 for (let i = 0; i < N; i++) {
342 const r = (i + 1) * P, rh = i * M, rv = (i + 1) * N;
343 for (let j = 0; j < N; j++) {
344 const d = u[r + P + j + 1] - u[r + j + 1], zz = zv[rv + j], e = zz * d + 0.5 * d * d;
345 fvB[j] = T * d + 2 * beta * e * (zz + d);
346 { const s = zz + d; cv[rv + j] = T + 2 * beta * (s * s + zz * d + 0.5 * d * d); }
347 }
348 let d = u[r + 1], zz = zh[rh], e = zz * d + 0.5 * d * d, hp = T * d + 2 * beta * e * (zz + d);
349 { const s = zz + d; ch[rh] = T + 2 * beta * (s * s + zz * d + 0.5 * d * d); }
350 for (let j = 0; j < N; j++) {
351 const q = r + j + 1;
352 d = u[q + 1] - u[q]; zz = zh[rh + j + 1]; e = zz * d + 0.5 * d * d;
353 const hn = T * d + 2 * beta * e * (zz + d);
354 { const s = zz + d; ch[rh + j + 1] = T + 2 * beta * (s * s + zz * d + 0.5 * d * d); }
355 const fo = hn - hp + fvB[j] - fvA[j] - Kb * (L1[q - P] + L1[q + P] + L1[q - 1] + L1[q + 1] - 4 * L1[q]);
356 out[q] = fo; vv[q] += hh * fo; hp = hn;
357 }
358 fvA.set(fvB);
359 }
360 }
361 // linearized force on du at the state of the last forceKickKeep, and the half kick
362 function tforceKick(du, out, vv, hh) {
363 for (let j = 0; j < N; j++) gvA[j] = cv[j] * du[P + j + 1];
364 lapInto(du, dL1);
365 for (let i = 0; i < N; i++) {
366 const r = (i + 1) * P, rh = i * M, rv = (i + 1) * N;
367 for (let j = 0; j < N; j++) gvB[j] = cv[rv + j] * (du[r + P + j + 1] - du[r + j + 1]);
368 let hp = ch[rh] * du[r + 1];
369 for (let j = 0; j < N; j++) {
370 const q = r + j + 1, hn = ch[rh + j + 1] * (du[q + 1] - du[q]);
371 const fo = hn - hp + gvB[j] - gvA[j] - Kb * (dL1[q - P] + dL1[q + P] + dL1[q - 1] + dL1[q + 1] - 4 * dL1[q]);
372 out[q] = fo; vv[q] += hh * fo; hp = hn;
373 }
374 gvA.set(gvB);
375 }
376 }
377 // damp vin into vout, half kick with ff, drift uu
378 function dampKickDrift(vin, vout, ff, uu) {
379 for (let i = 1; i <= N; i++) {
380 const r = i * P;
381 for (let q = r + 1; q <= r + N; q++) {
382 const x = vin[q], y = x - h * (c0 * x - c1 * (vin[q - P] + vin[q + P] + vin[q - 1] + vin[q + 1] - 4 * x)) + hh * ff[q];
383 vout[q] = y; uu[q] += h * y;
384 }
385 }
386 }
387 function kickDrift(vv, ff, uu) {
388 for (let i = 1; i <= N; i++) { const r = i * P; for (let q = r + 1; q <= r + N; q++) { const y = vv[q] + hh * ff[q]; vv[q] = y; uu[q] += h * y; } }
389 }
390 function damp(vin, vout) {
391 for (let i = 1; i <= N; i++) {
392 const r = i * P;
393 for (let q = r + 1; q <= r + N; q++) { const x = vin[q]; vout[q] = x - h * (c0 * x - c1 * (vin[q - P] + vin[q + P] + vin[q - 1] + vin[q + 1] - 4 * x)); }
394 }
395 }
396 function normalize(a, b) {
397 let s = 0;
398 for (let i = 1; i <= N; i++) for (let q = i * P + 1; q <= i * P + N; q++) s += a[q] * a[q] + b[q] * b[q];
399 const n = Math.sqrt(s);
400 for (let i = 1; i <= N; i++) for (let q = i * P + 1; q <= i * P + N; q++) { a[q] /= n; b[q] /= n; }
401 return n;
402 }
404 const nsub = Math.max(1, Math.ceil(p.dtOut / p.hmax)), h = p.dtOut / nsub, hh = 0.5 * h;
405 const nOut = p.nOut, K = p.decim, nD = Math.floor(nOut / K), NB = p.bandOf.nb;
406 const sig = new Float32Array(nOut);
407 const snapN = opts.snap ? Math.min(nOut, p.snapN) : 0, snap = opts.snap ? new Float32Array(snapN * NN) : null;
408 const bands = opts.modes ? new Float32Array(nD * NB) : null;
409 const hi1 = new Float32Array(nD), hi4 = new Float32Array(nD), energy = new Float32Array(nD);
410 const logsum = tan ? new Float64Array(nD) : null;
411 const finalBands = new Float32Array(NB), es = new Float64Array(NN), bs = new Float64Array(NB), tmp = new Float64Array(NN);
412 const Q = new Float64Array(NN), Pm = new Float64Array(NN);
413 const pk = (p.lpi + 1) * P + p.lpj + 1;
414 let L = 0;
415 function modeEnergies() {
416 // q = S w S, p = S v S (S symmetric)
417 const S = sh.S;
418 const proj = (x, out) => {
419 for (let i = 0; i < N; i++) for (let b = 0; b < N; b++) { let s = 0; const r = (i + 1) * P + 1; for (let j = 0; j < N; j++) s += x[r + j] * S[j * N + b]; tmp[i * N + b] = s; }
420 for (let a = 0; a < N; a++) for (let b = 0; b < N; b++) { let s = 0; for (let i = 0; i < N; i++) s += S[a * N + i] * tmp[i * N + b]; out[a * N + b] = s; }
421 };
422 proj(w, Q); proj(v, Pm);
423 let tot = 0;
424 for (let k = 0; k < NN; k++) { const e = 0.5 * (Pm[k] * Pm[k] + sh.om[k] * sh.om[k] * Q[k] * Q[k]); es[k] = e; tot += e; }
425 bs.fill(0);
426 for (let k = 0; k < NN; k++) bs[p.bandOf.idx[k]] += es[k] / (tot || 1);
427 return tot;
428 }
429 const scratch = new Float64Array(PP);
430 if (tan) { forceKickKeep(w, f, scratch, 0); tforceKick(dw, g, scratch, 0); } else forceKick(w, f, scratch, 0);
431 for (let i = 0; i < nOut; i++) {
432 for (let s = 0; s < nsub; s++) {
433 // damping of the previous substep (that of the last substep of each output sample is applied below), kick, drift
434 if (s) {
435 dampKickDrift(v, vb, f, w); let t = v; v = vb; vb = t;
436 if (tan) { dampKickDrift(dv, dvb, g, dw); t = dv; dv = dvb; dvb = t; }
437 } else {
438 kickDrift(v, f, w);
439 if (tan) kickDrift(dv, g, dw);
440 }
441 if (tan) { forceKickKeep(w, f, v, hh); tforceKick(dw, g, dv, hh); } else forceKick(w, f, v, hh);
442 }
443 damp(v, vb); let t = v; v = vb; vb = t;
444 if (tan) { damp(dv, dvb); t = dv; dv = dvb; dvb = t; }
445 if (!Number.isFinite(v[P + 1]) || !Number.isFinite(v[pk])) return { blowup: true };
446 if (tan) { L += Math.log(normalize(dw, dv)); tforceKick(dw, g, scratch, 0); }
447 if (p.listenSum) { let sv = 0; for (let a = 1; a <= N; a++) for (let q = a * P + 1; q <= a * P + N; q++) sv += v[q]; sig[i] = sv; } else sig[i] = v[pk];
448 if (i < snapN) for (let a = 0; a < N; a++) for (let b = 0; b < N; b++) snap[i * NN + a * N + b] = w[(a + 1) * P + b + 1];
449 if ((i + 1) % K === 0) {
450 const jd = (i + 1) / K - 1;
451 if (jd < nD) {
452 energy[jd] = modeEnergies();
453 let a1 = 0, a4 = 0;
454 for (let k = 0; k < NN; k++) { const fk = p.fk[k]; const sh_ = es[k] / (energy[jd] || 1); if (fk > 1000) a1 += sh_; if (fk > 4000) a4 += sh_; }
455 hi1[jd] = a1; hi4[jd] = a4;
456 if (bands) for (let b = 0; b < NB; b++) bands[jd * NB + b] = bs[b];
457 if (logsum) logsum[jd] = L;
458 }
459 }
460 }
461 modeEnergies();
462 for (let b = 0; b < NB; b++) finalBands[b] = bs[b];
463 return { sig, snap, bands, hi1, hi4, energy, logsum, finalBands };
464}
466onmessage = (ev) => {
467 const { gen, p, k, lyap } = ev.data;
468 const sh = setupSheet(p);
469 if (lyap) {
470 // strike A again, only for the Lyapunov exponent (run in parallel with the other strikes)
471 let r = strikeRun(p, sh, 1, { tangent: true });
472 if (r.blowup) r = strikeRun(Object.assign({}, p, { hmax: p.hmax / 3 }), sh, 1, { tangent: true });
473 if (r.blowup) { postMessage({ gen, lyap: true, failed: true }); return; }
474 postMessage({ gen, lyap: true, logsum: r.logsum }, [r.logsum.buffer]);
475 return;
476 }
477 const opts = { snap: k === 0, modes: k === 0, tangent: false };
478 let r = strikeRun(p, sh, 1 + k * p.delta, opts);
479 if (r.blowup) r = strikeRun(Object.assign({}, p, { hmax: p.hmax / 3 }), sh, 1 + k * p.delta, opts);
480 if (r.blowup) { postMessage({ gen, k, failed: true }); return; }
481 const tr = [r.sig.buffer, r.hi1.buffer, r.hi4.buffer, r.energy.buffer, r.finalBands.buffer];
482 r.logsum = null;
483 if (r.snap) tr.push(r.snap.buffer);
484 if (r.bands) tr.push(r.bands.buffer);
485 postMessage({ gen, k, r }, tr);
486};
487</script>
489<script>
490(() => {
491 const SR = 44100, NS = 8;
492 const $ = (id) => document.getElementById(id);
493 const css = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
494 const fmt = (v, d = 2) => Number(v).toFixed(d);
496 const PRESETS = {
497 soft: { E0: 1000, Kb: 25, Hd: 10, N: 24, sx: 0.35, sy: 0.42, rad: 1.6, dl: -6, tlo: 4, thi: 0.8, f1: 100, dur: 2.5 },
498 gong: { E0: 10000, Kb: 25, Hd: 10, N: 24, sx: 0.35, sy: 0.42, rad: 1.6, dl: -6, tlo: 4, thi: 0.8, f1: 100, dur: 2.5 },
499 crash: { E0: 30000, Kb: 25, Hd: 10, N: 24, sx: 0.35, sy: 0.42, rad: 1.6, dl: -6, tlo: 4, thi: 0.8, f1: 100, dur: 2.5 },
500 flat: { E0: 10000, Kb: 25, Hd: 0, N: 24, sx: 0.35, sy: 0.42, rad: 1.6, dl: -6, tlo: 4, thi: 0.8, f1: 100, dur: 2.5 },
501 // chaotic: strikes differing by 1e-6 end with different spectra (scan of 2026-10-05, chaos_scan.py)
502 cgong: { E0: 30000, Kb: 8, Hd: 10, N: 24, sx: 0.35, sy: 0.42, rad: 1.6, dl: -6, tlo: 6, thi: 2, f1: 150, dur: 2.5 },
503 ccrash: { E0: 100000, Kb: 25, Hd: 10, N: 24, sx: 0.35, sy: 0.42, rad: 1.6, dl: -6, tlo: 6, thi: 2, f1: 100, dur: 2.5 },
504 };
505 const eFromSlider = (v) => Math.pow(10, 2 + 3 * v / 1000);
506 const sliderFromE = (E) => Math.round((Math.log10(E) - 2) / 3 * 1000);
508 function sheetFreqs(N, Kb) {
509 const M = N + 1, s = [];
510 for (let k = 0; k < N; k++) s.push(4 * Math.sin((k + 1) * Math.PI / (2 * M)) ** 2);
511 const om = new Float64Array(N * N);
512 for (let a = 0; a < N; a++) for (let b = 0; b < N; b++) { const lam = s[a] + s[b]; om[a * N + b] = Math.sqrt(lam + Kb * lam * lam); }
513 return om;
514 }
516 function readParams() {
517 const E0 = eFromSlider(+$("E0").value), Kb = +$("Kb").value, Hd = +$("Hd").value, N = +$("N").value;
518 const sx = +$("sx").value, sy = +$("sy").value, rad = +$("rad").value, dl = +$("dl").value;
519 const tlo = +$("tlo").value, thi = Math.min(+$("thi").value, tlo), dur = +$("dur").value;
520 const om = sheetFreqs(N, Kb);
521 let om1 = Infinity, omN = 0;
522 for (const o of om) { om1 = Math.min(om1, o); omN = Math.max(omN, o); }
523 const f1max = 19000 / (omN / om1), f1 = Math.min(+$("f1").value, f1max), ftop = f1 * omN / om1;
524 const M = N + 1, ci = Math.max(1, Math.min(N, Math.round(sx * M))), cj = Math.max(1, Math.min(N, Math.round(sy * M)));
525 const lpi = Math.max(0, Math.min(N - 1, Math.round(+$("lx").value * M) - 1)), lpj = Math.max(0, Math.min(N - 1, Math.round(+$("ly").value * M) - 1));
526 const sum = $("lisSum").getAttribute("aria-pressed") === "true";
527 $("E0v").textContent = E0 >= 1000 ? fmt(E0 / 1000, E0 >= 10000 ? 0 : 1) + "k" : fmt(E0, 0);
528 $("Kbv").textContent = fmt(Kb, 1);
529 $("Hdv").textContent = fmt(Hd, 1);
530 $("Nv").textContent = N + " × " + N;
531 $("sposv").textContent = "bead (" + ci + ", " + cj + ")";
532 $("radv").textContent = fmt(rad, 1) + " beads";
533 $("dlv").textContent = "1e" + dl + " in strength";
534 $("tlov").textContent = fmt(tlo, 1) + " s";
535 $("thiv").textContent = fmt(thi, 2) + " s";
536 $("f1v").textContent = Math.round(f1) + " Hz · top " + (ftop >= 1000 ? fmt(ftop / 1000, 1) + "k" : Math.round(ftop));
537 $("f1note").hidden = !(+$("f1").value > f1max);
538 $("durv").textContent = fmt(dur, 1) + " s";
539 $("lisv").textContent = sum ? "whole sheet" : "bead (" + (lpi + 1) + ", " + (lpj + 1) + ")";
540 $("lx").disabled = sum; $("ly").disabled = sum;
541 const tau = om1 / (2 * Math.PI * f1);
542 // damping: mode decay (amplitude) about (c0 + c1 lam) / 2, lam the Laplacian eigenvalue, from the ring times
543 const s1 = 4 * Math.sin(Math.PI / (2 * M)) ** 2, sN = 4 * Math.sin(N * Math.PI / (2 * M)) ** 2, lam1 = 2 * s1, lamN = 2 * sN;
544 const sLo = Math.log(1000) / tlo * tau, sHi = Math.log(1000) / thi * tau;
545 const c1 = Math.max(0, 2 * (sHi - sLo) / (lamN - lam1)), c0 = Math.max(0, 2 * sLo - c1 * lam1);
546 const fk = Array.from(om, (o) => f1 * o / om1);
547 // log-spaced bands from f1 to the top
548 const NB = 36, idx = new Int32Array(N * N), edges = [];
549 for (let b = 0; b <= NB; b++) edges.push(f1 * Math.pow(ftop * 1.0001 / f1, b / NB));
550 for (let k = 0; k < N * N; k++) { let b = Math.floor(Math.log(fk[k] / f1) / Math.log(ftop * 1.0001 / f1) * NB); idx[k] = Math.max(0, Math.min(NB - 1, b)); }
551 return {
552 E0, Kb, Hd, N, T: 1, beta: 1, ci, cj, rad, delta: Math.pow(10, dl), tau, c0, c1, dur, f1, ftop, fk,
553 bandOf: { nb: NB, idx, edges }, lpi, lpj, listenSum: sum,
554 dtOut: 1 / SR / tau, nOut: Math.round(dur * SR), decim: Math.round(SR / 400),
555 // leapfrog at about 0.6 of its linear stability limit; the band statistics agree with 4x smaller steps
556 // (checked 2026-10-05), and a strike that blows up is rerun with a third of the step
557 hmax: 1.2 / omN, snapN: Math.round(0.08 * SR),
558 };
559 }
560 function setControls(p) {
561 $("E0").value = sliderFromE(p.E0); $("Kb").value = p.Kb; $("Hd").value = p.Hd; $("N").value = p.N; $("sx").value = p.sx; $("sy").value = p.sy;
562 $("rad").value = p.rad; $("dl").value = p.dl; $("tlo").value = p.tlo; $("thi").value = p.thi; $("f1").value = p.f1; $("dur").value = p.dur;
563 }
565 // ---------------- workers ----------------
566 const workerURL = URL.createObjectURL(new Blob([$("worker-src").textContent], { type: "text/javascript" }));
567 const NW = Math.max(2, Math.min(NS + 1, (navigator.hardwareConcurrency || 4) - 1));
568 let workers = [], gen = 0, params = null, S = [], queue = [], done = 0, t0run = 0, lamA = null;
569 function run() {
570 stopAudio(); stopSlow(true);
571 params = readParams();
572 gen++;
573 for (const w of workers) w.terminate();
574 workers = []; S = new Array(NS).fill(null); done = 0; bufs = []; lamA = null;
575 queue = ["lyap"].concat(Array.from({ length: NS }, (_, k) => k));
576 t0run = performance.now();
577 for (let i = 0; i < Math.min(NW, NS); i++) { const w = new Worker(workerURL); w.onmessage = onResult; workers.push(w); next(w); }
578 $("status").textContent = "Computing 8 strikes in parallel…";
579 renderSelector(); updateStats(); drawAll();
580 }
581 function next(w) {
582 if (!queue.length) return;
583 const k = queue.shift();
584 if (k === "lyap") w.postMessage({ gen, p: params, k: 0, lyap: true }); else w.postMessage({ gen, p: params, k });
585 }
586 function onResult(ev) {
587 const m = ev.data;
588 if (m.gen !== gen) return;
589 next(ev.target);
590 if (m.failed) { $("status").textContent = "The strike blew up numerically; try a softer strike."; return; }
591 if (m.lyap) {
592 lamA = localLyap(m.logsum);
593 if (S[0]) S[0].lam = lamA;
594 done++;
595 } else {
596 const r = m.r; S[m.k] = r; done++;
597 if (m.k === 0) { r.lam = lamA; audioGain = 0.8 / (maxAbs(r.sig) || 1); }
598 }
599 if (S[0]) for (let k = 0; k < NS; k++) if (S[k] && !S[k].audio) S[k].audio = scaleAudio(S[k].sig);
600 $("status").textContent = done < NS + 1 ? "Computed " + done + " of 9 runs (8 strikes and the separation rate)…" : "Ready (" + fmt((performance.now() - t0run) / 1000, 1) + " s). Strikes differ by 1e" + $("dl").value + " in strength.";
601 renderSelector(); updateStats(); drawAll();
602 }
603 const maxAbs = (a) => { let m = 0; for (let i = 0; i < a.length; i++) m = Math.max(m, Math.abs(a[i])); return m; };
604 function localLyap(logsum) {
605 const dtD = params.decim * params.dtOut, w = Math.max(1, Math.round(0.05 / (params.decim / SR)));
606 const out = new Float32Array(logsum.length);
607 for (let i = 0; i < logsum.length; i++) { const j = Math.max(0, i - w); out[i] = i > j ? (logsum[i] - logsum[j]) / ((i - j) * dtD) / params.tau : NaN; }
608 return out;
609 }
610 function chaosEnd(lam) { let last = -1; for (let i = 0; i < lam.length; i++) if (lam[i] > 40) last = i; return last < 0 ? null : (last + 1) * params.decim / SR; }
611 function updateStats() {
612 const A = S[0];
613 if (!A) { for (const id of ["sBloom", "sBloomT", "sChaos", "sLam", "sSpread"]) $(id).textContent = "–"; return; }
614 let pk = 0, pi = 0;
615 for (let i = 0; i < A.hi4.length; i++) if (A.hi4[i] > pk) { pk = A.hi4[i]; pi = i; }
616 $("sBloom").textContent = fmt(100 * pk, pk < 0.01 ? 2 : 0) + "%";
617 $("sBloomT").textContent = fmt((pi + 1) * params.decim / SR * 1000, 0) + " ms";
618 if (!A.lam) { $("sChaos").textContent = "computing…"; $("sLam").textContent = "computing…"; }
619 const end = A.lam ? chaosEnd(A.lam) : null;
620 if (A.lam) $("sChaos").textContent = end === null ? "never chaotic" : end > params.dur - 0.15 ? "still chaotic at the end" : fmt(end, 2) + " s";
621 let peak = 0; if (A.lam) for (const v of A.lam) if (v > peak) peak = v;
622 if (A.lam) $("sLam").textContent = peak > 40 ? fmt(peak, 0) + " /s" : "≈ 0";
623 if (S.some((x) => !x)) { $("sSpread").textContent = "computing…"; return; }
624 const NB = params.bandOf.nb, sd = [];
625 for (let b = 0; b < NB; b++) {
626 const mean = S.reduce((s, x) => s + x.finalBands[b], 0) / NS;
627 if (mean < 1e-3) continue;
628 const vals = S.map((x) => Math.log10(Math.max(x.finalBands[b], 1e-12))), m = vals.reduce((s, v) => s + v, 0) / NS;
629 sd.push(Math.sqrt(vals.reduce((s, v) => s + (v - m) ** 2, 0) / NS));
630 }
631 sd.sort((a, b) => a - b);
632 const med = sd.length ? sd[Math.floor(sd.length / 2)] : 0;
633 $("sSpread").textContent = med < 0.01 ? "identical" : "differ by " + fmt(10 * med, 1) + " dB";
634 }
636 // ---------------- selector ----------------
637 let sel = 0;
638 const strikeColor = (k) => (k === 0 ? css("--x") : k === 1 ? css("--y") : css("--ink"));
639 const strikeName = (k) => (k === 0 ? "A" : k === 1 ? "B" : String(k + 1));
640 function renderSelector() {
641 const box = $("strikeSel");
642 if (!box.childElementCount) {
643 for (let k = 0; k < NS; k++) { const b = document.createElement("button"); b.type = "button"; b.className = "sk"; b.id = "sk" + k; b.innerHTML = '<span class="sw"></span>' + strikeName(k); b.addEventListener("click", () => selectStrike(k)); box.appendChild(b); }
644 const pb = document.createElement("button"); pb.type = "button"; pb.className = "btn"; pb.id = "playSel"; pb.textContent = "Play this strike"; pb.addEventListener("click", () => play(sel, null)); box.appendChild(pb);
645 }
646 for (let k = 0; k < NS; k++) { const b = $("sk" + k); b.disabled = !S[k]; b.setAttribute("aria-pressed", String(k === sel)); b.querySelector(".sw").style.background = strikeColor(k); }
647 $("playSel").disabled = !S[sel];
648 }
649 function selectStrike(k) { if (!S[k]) return; sel = k; renderSelector(); drawHi(); drawFinal(); drawSpec(); }
650 document.addEventListener("keydown", (e) => {
651 if (e.target && (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")) return;
652 if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
653 const dir = e.key === "ArrowRight" ? 1 : -1;
654 for (let i = 1; i <= NS; i++) { const k = (sel + dir * i + NS) % NS; if (S[k]) { selectStrike(k); e.preventDefault(); return; } }
655 });
657 // ---------------- canvas helpers ----------------
658 function setup(canvas) {
659 const r = canvas.getBoundingClientRect(), dpr = window.devicePixelRatio || 1;
660 const w = Math.max(1, Math.round(r.width * dpr)), h = Math.max(1, Math.round(r.height * dpr));
661 if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
662 const ctx = canvas.getContext("2d"); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, r.width, r.height);
663 return { ctx, W: r.width, H: r.height };
664 }
665 const PAD = { l: 48, r: 12, t: 10, b: 26 };
666 function niceStep(v) { const p = Math.pow(10, Math.floor(Math.log10(v))), m = v / p; return (m < 1.5 ? 1 : m < 3.5 ? 2 : m < 7.5 ? 5 : 10) * p; }
667 const hz = (f) => (f >= 1000 ? fmt(f / 1000, f >= 10000 || f % 1000 === 0 ? 0 : 1) + "k" : String(Math.round(f)));
668 function axesTime(ctx, W, H, ymin, ymax, yticks, yfmt, dur) {
669 const x0 = PAD.l, x1 = W - PAD.r, y0 = PAD.t, y1 = H - PAD.b;
670 ctx.strokeStyle = css("--grid"); ctx.lineWidth = 1; ctx.fillStyle = css("--muted"); ctx.font = "11px " + css("--mono");
671 ctx.textAlign = "right"; ctx.textBaseline = "middle";
672 for (const v of yticks) { const y = y1 - (v - ymin) / (ymax - ymin) * (y1 - y0); ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x1, y); ctx.stroke(); ctx.fillText(yfmt(v), x0 - 6, y); }
673 ctx.textAlign = "center"; ctx.textBaseline = "top";
674 const step = dur <= 2 ? 0.25 : 0.5;
675 for (let t = 0; t <= dur + 1e-9; t += step) { const x = x0 + t / dur * (x1 - x0); ctx.beginPath(); ctx.moveTo(x, y0); ctx.lineTo(x, y1); ctx.stroke(); ctx.fillText(fmt(t, step < 0.5 ? 2 : 1) + (t === 0 ? " s" : ""), x, y1 + 6); }
676 return { x0, x1, y0, y1, X: (t) => x0 + t / dur * (x1 - x0), Y: (v) => y1 - (v - ymin) / (ymax - ymin) * (y1 - y0) };
677 }
678 function chaosSpan(ctx, ax) { if (!S[0] || !S[0].lam) return; const end = chaosEnd(S[0].lam); if (end === null) return; ctx.fillStyle = css("--chaos-wash"); ctx.fillRect(ax.x0, ax.y0, ax.X(end) - ax.x0, ax.y1 - ax.y0); }
679 function seriesTime(ctx, ax, arr, color, width, dash) {
680 if (!arr) return;
681 const dt = params.decim / SR;
682 ctx.strokeStyle = color; ctx.lineWidth = width; ctx.setLineDash(dash || []); ctx.beginPath();
683 let started = false;
684 for (let i = 0; i < arr.length; i++) { const v = arr[i]; if (!Number.isFinite(v)) { started = false; continue; } const x = ax.X((i + 1) * dt), y = ax.Y(v); if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y); }
685 ctx.stroke(); ctx.setLineDash([]);
686 }
687 function hexToRgb(h) { h = h.replace("#", ""); if (h.length === 3) h = h.split("").map((c) => c + c).join(""); const n = parseInt(h, 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }
688 function mix(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }
689 function heatColor(v, bg, ink, hot) { return v < 0.72 ? mix(bg, ink, v / 0.72) : mix(ink, hot, (v - 0.72) / 0.28); }
691 // ---------------- the sheet ----------------
692 let smOn = false, smIdx = 0, smRaf = 0, smLast = 0;
693 function sheetGeom(W, H) { const N = params.N, pad = 14, side = Math.min(W, H) - 2 * pad, cell = side / (N + 1); return { N, pad, side, cell, x0: (W - side) / 2, y0: (H - side) / 2 }; }
694 function drawSheet() {
695 const { ctx, W, H } = setup($("cSheet"));
696 if (!params) return;
697 const g = sheetGeom(W, H), N = g.N, A = S[0];
698 // frame (pinned edge)
699 ctx.fillStyle = css("--rule"); ctx.fillRect(g.x0, g.y0, g.side, g.side);
700 const bg = hexToRgb(css("--panel")), up = hexToRgb(css("--up")), dn = hexToRgb(css("--down"));
701 let wm = 0;
702 const w = new Float32Array(N * N);
703 if (A && A.snap) {
704 const nS = A.snap.length / (N * N);
705 for (let i = 0; i < A.snap.length; i += 7) wm = Math.max(wm, Math.abs(A.snap[i]));
706 if (smOn) { const i = Math.min(nS - 1, Math.floor(smIdx)); for (let k = 0; k < N * N; k++) w[k] = A.snap[i * N * N + k]; }
707 }
708 // rest dome: faint concentric shading
709 for (let i = 0; i < N; i++) for (let j = 0; j < N; j++) {
710 const X = g.x0 + (j + 0.5 + 0.5) * g.cell, Y = g.y0 + (i + 0.5 + 0.5) * g.cell;
711 const t = wm > 0 ? w[i * N + j] / wm : 0;
712 const c = t >= 0 ? mix(bg, up, Math.min(1, t * 1.6)) : mix(bg, dn, Math.min(1, -t * 1.6));
713 ctx.fillStyle = "rgb(" + c.map(Math.round).join(",") + ")";
714 ctx.fillRect(X - g.cell * 0.46, Y - g.cell * 0.46, g.cell * 0.92, g.cell * 0.92);
715 }
716 if (params.Hd > 0) {
717 ctx.strokeStyle = css("--muted"); ctx.globalAlpha = 0.35; ctx.lineWidth = 1;
718 for (const fr of [0.25, 0.5, 0.75]) {
719 // contour of the dome x(1-x)y(1-y) = fr / 16: draw by marching a polar parametrization
720 ctx.beginPath();
721 for (let a = 0; a <= 64; a++) {
722 const th = 2 * Math.PI * a / 64; let lo = 0, hi = 0.5;
723 for (let it = 0; it < 30; it++) { const r = (lo + hi) / 2, x = 0.5 + r * Math.cos(th), y = 0.5 + r * Math.sin(th); if (16 * x * (1 - x) * y * (1 - y) > fr) lo = r; else hi = r; }
724 const r = (lo + hi) / 2, X = g.x0 + g.cell * 0.5 + (0.5 + r * Math.cos(th)) * g.cell * (N + 1) - g.cell * 0.5, Y = g.y0 + (0.5 + r * Math.sin(th)) * g.cell * (N + 1);
725 if (a === 0) ctx.moveTo(X, Y); else ctx.lineTo(X, Y);
726 }
727 ctx.stroke();
728 }
729 ctx.globalAlpha = 1;
730 }
731 // strike patch
732 const sX = g.x0 + (params.cj + 0.5) * g.cell, sY = g.y0 + (params.ci + 0.5) * g.cell;
733 if (!smOn) { ctx.strokeStyle = css("--chaos"); ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(sX, sY, Math.max(6, params.rad * g.cell * 1.2), 0, 2 * Math.PI); ctx.stroke(); }
734 // listener
735 ctx.strokeStyle = css("--y"); ctx.lineWidth = 2.2;
736 if (params.listenSum) { ctx.strokeRect(g.x0 + 3, g.y0 + 3, g.side - 6, g.side - 6); }
737 else { const lX = g.x0 + (params.lpj + 1.5) * g.cell, lY = g.y0 + (params.lpi + 1.5) * g.cell; ctx.beginPath(); ctx.arc(lX, lY, Math.max(6, g.cell * 0.8), 0, 2 * Math.PI); ctx.stroke(); }
738 ctx.fillStyle = css("--ink"); ctx.font = "11px " + css("--mono"); ctx.textAlign = "left"; ctx.textBaseline = "top";
739 if (smOn && A) ctx.fillText("t = " + fmt(smIdx / SR * 1000, 2) + " ms", g.x0 + 6, g.y0 + 4);
740 }
741 function smTick(now) {
742 const A = S[0];
743 if (!smOn || !A) return;
744 const dtReal = smLast ? Math.min(0.1, (now - smLast) / 1000) : 0;
745 smLast = now; smIdx += 0.004 * dtReal * SR;
746 if (smIdx >= A.snap.length / (params.N * params.N) - 1) { stopSlow(); return; }
747 drawSheet(); smRaf = requestAnimationFrame(smTick);
748 }
749 function startSlow() { if (!S[0]) return; smOn = true; smIdx = 0; smLast = 0; $("slow").setAttribute("aria-pressed", "true"); $("slow").textContent = "Stop slow motion"; smRaf = requestAnimationFrame(smTick); }
750 function stopSlow(silent) { smOn = false; cancelAnimationFrame(smRaf); $("slow").setAttribute("aria-pressed", "false"); $("slow").textContent = "Slow motion"; if (!silent) drawSheet(); }
751 $("cSheet").addEventListener("click", (e) => {
752 if (!params) return;
753 const r = $("cSheet").getBoundingClientRect(), g = sheetGeom(r.width, r.height);
754 const fx = (e.clientX - r.left - g.x0) / g.side, fy = (e.clientY - r.top - g.y0) / g.side;
755 if (fx < 0 || fx > 1 || fy < 0 || fy > 1) return;
756 if (e.shiftKey) {
757 $("lx").value = Math.min(0.95, Math.max(0.05, fy)); $("ly").value = Math.min(0.95, Math.max(0.05, fx));
758 if ($("lisSum").getAttribute("aria-pressed") === "true") { $("lisSum").setAttribute("aria-pressed", "false"); $("lisOne").setAttribute("aria-pressed", "true"); }
759 } else { $("sx").value = Math.min(0.92, Math.max(0.08, fy)); $("sy").value = Math.min(0.92, Math.max(0.08, fx)); }
760 readParams(); run();
761 });
763 // ---------------- plots ----------------
764 function drawModes() {
765 const { ctx, W, H } = setup($("cModes"));
766 const ax = axesTime(ctx, W, H, 0, 1, [], () => "", params.dur);
767 const A = S[0];
768 if (!A || !A.bands) return;
769 const NB = params.bandOf.nb, nD = A.hi4.length, off = document.createElement("canvas");
770 off.width = nD; off.height = NB;
771 const octx = off.getContext("2d"), img = octx.createImageData(nD, NB);
772 const bg = hexToRgb(css("--panel")), ink = hexToRgb(css("--ink")), hot = hexToRgb(css("--chaos"));
773 for (let i = 0; i < nD; i++) for (let b = 0; b < NB; b++) {
774 const v = Math.min(1, Math.max(0, (Math.log10(Math.max(A.bands[i * NB + b], 1e-12)) + 6) / 6));
775 const c = heatColor(v, bg, ink, hot), o = ((NB - 1 - b) * nD + i) * 4;
776 img.data[o] = c[0]; img.data[o + 1] = c[1]; img.data[o + 2] = c[2]; img.data[o + 3] = 255;
777 }
778 octx.putImageData(img, 0, 0);
779 ctx.imageSmoothingEnabled = false;
780 ctx.drawImage(off, ax.x0, ax.y0, ax.x1 - ax.x0, ax.y1 - ax.y0);
781 ctx.fillStyle = css("--muted"); ctx.font = "11px " + css("--mono"); ctx.textAlign = "right"; ctx.textBaseline = "middle";
782 const lf0 = Math.log(params.f1), lf1 = Math.log(params.ftop);
783 for (const f of [100, 200, 500, 1000, 2000, 4000, 8000, 16000]) if (f >= params.f1 && f <= params.ftop) ctx.fillText(hz(f), ax.x0 - 6, ax.y1 - (Math.log(f) - lf0) / (lf1 - lf0) * (ax.y1 - ax.y0));
784 }
785 function drawHi() {
786 const { ctx, W, H } = setup($("cHi"));
787 const ax = axesTime(ctx, W, H, 0, 1, [0, 0.25, 0.5, 0.75, 1], (v) => fmt(v * 100, 0) + "%", params.dur);
788 chaosSpan(ctx, ax);
789 for (let k = NS - 1; k >= 0; k--) { const x = S[k]; if (!x) continue; const col = k === 0 ? css("--x") : k === 1 ? css("--y") : css("--faint"); seriesTime(ctx, ax, x.hi1, col, 0.8); seriesTime(ctx, ax, x.hi4, col, k <= 1 ? 1.6 : 1); }
790 if (S[sel]) { seriesTime(ctx, ax, S[sel].hi4, strikeColor(sel), 3); seriesTime(ctx, ax, S[sel].hi1, strikeColor(sel), 1.4, [4, 3]); }
791 }
792 function drawFinal() {
793 const { ctx, W, H } = setup($("cFinal"));
794 if (!params) return;
795 const NB = params.bandOf.nb, e = params.bandOf.edges, x0 = PAD.l, x1 = W - PAD.r, y0 = PAD.t, y1 = H - PAD.b, lo = -8, hi = 0;
796 const lf0 = Math.log10(e[0]), lf1 = Math.log10(e[NB]);
797 const X = (f) => x0 + (Math.log10(f) - lf0) / (lf1 - lf0) * (x1 - x0), Y = (v) => y1 - (v - lo) / (hi - lo) * (y1 - y0);
798 ctx.strokeStyle = css("--grid"); ctx.lineWidth = 1; ctx.fillStyle = css("--muted"); ctx.font = "11px " + css("--mono");
799 ctx.textAlign = "right"; ctx.textBaseline = "middle";
800 for (let v = lo; v <= hi; v += 2) { ctx.beginPath(); ctx.moveTo(x0, Y(v)); ctx.lineTo(x1, Y(v)); ctx.stroke(); ctx.fillText("1e" + v, x0 - 6, Y(v)); }
801 ctx.textAlign = "center"; ctx.textBaseline = "top";
802 for (const f of [100, 200, 500, 1000, 2000, 5000, 10000]) if (f >= e[0] && f <= e[NB]) { ctx.beginPath(); ctx.moveTo(X(f), y0); ctx.lineTo(X(f), y1); ctx.stroke(); ctx.fillText(hz(f), X(f), y1 + 6); }
803 const line = (s, color, w) => { if (!s) return; ctx.strokeStyle = color; ctx.lineWidth = w; ctx.beginPath(); for (let b = 0; b < NB; b++) { const fc = Math.sqrt(e[b] * e[b + 1]), v = Math.max(lo, Math.log10(Math.max(s[b], 1e-12))); if (b === 0) ctx.moveTo(X(fc), Y(v)); else ctx.lineTo(X(fc), Y(v)); } ctx.stroke(); };
804 for (let k = 2; k < NS; k++) if (S[k]) line(S[k].finalBands, css("--faint"), 1);
805 if (S[1]) line(S[1].finalBands, css("--y"), 1.4);
806 if (S[0]) line(S[0].finalBands, css("--x"), 1.6);
807 if (S[sel]) line(S[sel].finalBands, strikeColor(sel), 3);
808 }
809 function drawLam() {
810 const { ctx, W, H } = setup($("cLam"));
811 const A = S[0];
812 if (!A || !A.lam) { axesTime(ctx, W, H, 0, 1, [0, 1], () => "", params.dur); return; }
813 let mx = 0; for (const v of A.lam) if (Number.isFinite(v) && v > mx) mx = v;
814 mx = Math.max(mx * 1.1, 60);
815 const st = niceStep(mx / 3), ticks = []; for (let v = 0; v <= mx; v += st) ticks.push(v);
816 const ax = axesTime(ctx, W, H, -0.15 * mx, mx, ticks, (v) => fmt(v, 0), params.dur);
817 chaosSpan(ctx, ax);
818 ctx.strokeStyle = css("--rule"); ctx.beginPath(); ctx.moveTo(ax.x0, ax.Y(0)); ctx.lineTo(ax.x1, ax.Y(0)); ctx.stroke();
819 const e = A.energy, e0 = e[0] || 1, eDb = new Float32Array(e.length);
820 for (let i = 0; i < e.length; i++) eDb[i] = -0.15 * mx + (Math.max(10 * Math.log10(e[i] / e0), -80) + 80) / 80 * 1.15 * mx;
821 seriesTime(ctx, ax, eDb, css("--muted"), 1, [4, 4]);
822 seriesTime(ctx, ax, A.lam, css("--x"), 1.6);
823 }
825 // ---------------- spectrogram ----------------
826 function fft(re, im) {
827 const n = re.length;
828 for (let i = 1, j = 0; i < n; i++) { let bit = n >> 1; for (; j & bit; bit >>= 1) j ^= bit; j ^= bit; if (i < j) { [re[i], re[j]] = [re[j], re[i]]; [im[i], im[j]] = [im[j], im[i]]; } }
829 for (let len = 2; len <= n; len <<= 1) { const ang = -2 * Math.PI / len, wr = Math.cos(ang), wi = Math.sin(ang); for (let i = 0; i < n; i += len) { let cr = 1, ci = 0; for (let k = 0; k < len / 2; k++) { const a = i + k, b = a + len / 2; const tr = re[b] * cr - im[b] * ci, ti = re[b] * ci + im[b] * cr; re[b] = re[a] - tr; im[b] = im[a] - ti; re[a] += tr; im[a] += ti; const ncr = cr * wr - ci * wi; ci = cr * wi + ci * wr; cr = ncr; } } }
830 }
831 const specFmax = () => Math.min(SR / 2, Math.max(2000, 1.1 * params.ftop));
832 function spectrogramOf(x) {
833 if (x.spec) return x.spec;
834 const sig = x.sig, N = 1024, hop = 256, frames = Math.max(1, Math.floor((sig.length - N) / hop)), bins = Math.floor(specFmax() / (SR / N));
835 const win = new Float32Array(N); for (let i = 0; i < N; i++) win[i] = 0.5 - 0.5 * Math.cos(2 * Math.PI * i / (N - 1));
836 const mag = new Float32Array(frames * bins), re = new Float64Array(N), im = new Float64Array(N);
837 let mx = -Infinity;
838 for (let f = 0; f < frames; f++) { for (let i = 0; i < N; i++) { re[i] = sig[f * hop + i] * win[i]; im[i] = 0; } fft(re, im); for (let b = 0; b < bins; b++) { const v = 10 * Math.log10(re[b] * re[b] + im[b] * im[b] + 1e-20); mag[f * bins + b] = v; if (v > mx) mx = v; } }
839 x.spec = { mag, frames, bins, mx };
840 return x.spec;
841 }
842 function drawSpec() {
843 const { ctx, W, H } = setup($("cSpec"));
844 const ax = axesTime(ctx, W, H, 0, 1, [], () => "", params.dur);
845 const X = S[sel] || S[0];
846 if (!X || !S[0]) return;
847 const ref = spectrogramOf(S[0]).mx, { mag, frames, bins } = spectrogramOf(X);
848 const off = document.createElement("canvas"); off.width = frames; off.height = bins;
849 const octx = off.getContext("2d"), img = octx.createImageData(frames, bins);
850 const bg = hexToRgb(css("--panel")), ink = hexToRgb(css("--ink")), hot = hexToRgb(css("--chaos"));
851 for (let f = 0; f < frames; f++) for (let b = 0; b < bins; b++) { const v = Math.min(1, Math.max(0, (mag[f * bins + b] - (ref - 80)) / 80)); const c = heatColor(v, bg, ink, hot), o = ((bins - 1 - b) * frames + f) * 4; img.data[o] = c[0]; img.data[o + 1] = c[1]; img.data[o + 2] = c[2]; img.data[o + 3] = 255; }
852 octx.putImageData(img, 0, 0);
853 const t0 = 512 / SR, t1 = (frames * 256 + 512) / SR;
854 ctx.drawImage(off, ax.X(t0), ax.y0, ax.X(t1) - ax.X(t0), ax.y1 - ax.y0);
855 ctx.fillStyle = css("--muted"); ctx.font = "11px " + css("--mono"); ctx.textAlign = "right"; ctx.textBaseline = "middle";
856 const fm = specFmax(), step = niceStep(fm / 4);
857 for (let f = 0; f <= fm; f += step) ctx.fillText(hz(f), ax.x0 - 6, ax.y1 - f / fm * (ax.y1 - ax.y0));
858 const ks = S[sel] ? sel : 0;
859 ctx.fillStyle = strikeColor(ks); ctx.font = "600 12px " + css("--sans"); ctx.textAlign = "right"; ctx.textBaseline = "top"; ctx.fillText("strike " + strikeName(ks), ax.x1 - 6, ax.y0 + 4);
860 }
861 function drawAll() { if (!params) return; drawSheet(); drawModes(); drawHi(); drawFinal(); drawLam(); drawSpec(); drawOverlays(null, null); }
863 // ---------------- audio ----------------
864 let actx = null, bufs = [], src = null, playStart = 0, playing = null, raf = 0, audioGain = 1;
865 function scaleAudio(s) { const o = new Float32Array(s.length), fade = Math.round(0.01 * SR); for (let i = 0; i < s.length; i++) { const tail = s.length - 1 - i; o[i] = Math.max(-1, Math.min(1, s[i] * audioGain * (tail < fade ? tail / fade : 1))); } return o; }
866 function ensureCtx() { if (!actx) actx = new (window.AudioContext || window.webkitAudioContext)(); if (actx.state === "suspended") actx.resume(); }
867 function toBuffer(d) { const b = actx.createBuffer(1, d.length, SR); b.copyToChannel(d, 0); return b; }
868 function stopAudio() { if (src) { try { src.onended = null; src.stop(); } catch (e) {} src = null; } playing = null; cancelAnimationFrame(raf); for (const id of ["playA", "playB", "playAB", "playSel"]) if ($(id)) $(id).setAttribute("aria-pressed", "false"); drawOverlays(null, null); }
869 function play(k, then) {
870 const x = S[k]; if (!x || !x.audio) return;
871 ensureCtx(); stopAudio();
872 if (!bufs[k]) bufs[k] = toBuffer(x.audio);
873 src = actx.createBufferSource(); src.buffer = bufs[k]; src.connect(actx.destination);
874 const nx = then === null || then === undefined ? null : then;
875 src.onended = () => { src = null; if (nx !== null) setTimeout(() => play(nx, null), 350); else stopAudio(); };
876 playStart = actx.currentTime; playing = k; src.start();
877 if (k === 0) $("playA").setAttribute("aria-pressed", "true"); else if (k === 1) $("playB").setAttribute("aria-pressed", "true");
878 if (k === sel && $("playSel")) $("playSel").setAttribute("aria-pressed", "true");
879 if (nx !== null) $("playAB").setAttribute("aria-pressed", "true");
880 tick();
881 }
882 function tick() { if (playing === null) return; drawOverlays(actx.currentTime - playStart, playing); raf = requestAnimationFrame(tick); }
883 function drawOverlays(t, which) {
884 for (const [id, base] of [["cModesO", "cModes"], ["cHiO", "cHi"], ["cLamO", "cLam"], ["cSpecO", "cSpec"]]) {
885 const { ctx, W } = setup($(id)); if (t === null) continue;
886 const H = $(base).getBoundingClientRect().height, x = PAD.l + Math.min(t, params.dur) / params.dur * (W - PAD.r - PAD.l);
887 ctx.strokeStyle = strikeColor(which); ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x, PAD.t); ctx.lineTo(x, H - PAD.b); ctx.stroke();
888 }
889 }
891 // ---------------- wiring ----------------
892 $("playA").addEventListener("click", () => play(0, null));
893 $("playB").addEventListener("click", () => play(1, null));
894 $("playAB").addEventListener("click", () => play(0, 1));
895 $("stop").addEventListener("click", stopAudio);
896 $("slow").addEventListener("click", () => (smOn ? stopSlow() : startSlow()));
897 for (const [id, other] of [["lisOne", "lisSum"], ["lisSum", "lisOne"]]) $(id).addEventListener("click", () => {
898 if ($(id).getAttribute("aria-pressed") === "true") return;
899 $(id).setAttribute("aria-pressed", "true"); $(other).setAttribute("aria-pressed", "false"); readParams(); run();
900 });
901 let timer = 0;
902 for (const id of ["lx", "ly", "E0", "Kb", "Hd", "N", "sx", "sy", "rad", "dl", "tlo", "thi", "f1", "dur"]) $(id).addEventListener("input", () => { readParams(); clearTimeout(timer); timer = setTimeout(run, 400); });
903 document.querySelectorAll("[data-preset]").forEach((b) => b.addEventListener("click", () => { setControls(PRESETS[b.dataset.preset]); run(); }));
904 let rt = 0;
905 window.addEventListener("resize", () => { clearTimeout(rt); rt = setTimeout(drawAll, 120); });
906 try { window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", drawAll); new MutationObserver(drawAll).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); } catch (e) {}
908 $("lx").value = 0.62; $("ly").value = 0.7;
909 setControls(PRESETS.gong);
910 run();
911})();
912</script>