70cc577Struck FPUT chain: model, experiments, figures, web appJeremy Magland 1<meta charset="utf-8">
2<title>Struck FPUT Chain</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 --chaos-wash: rgba(232, 185, 49, 0.18);
18 --faint: rgba(23, 35, 52, 0.16);
19 --focus: #2563a6;
20 --sans: "Archivo", "Helvetica Neue", Arial, sans-serif;
21 --mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
22}
23@media (prefers-color-scheme: dark) {
24 :root:not([data-theme="light"]) {
25 color-scheme: dark;
26 --paper: #0f161f; --panel: #16202c; --ink: #e3e9f0; --muted: #93a3b5; --rule: #2a3747; --grid: #1f2a37;
27 --x: #6fa8e8; --y: #d57bae; --chaos: #e8b931; --chaos-wash: rgba(232, 185, 49, 0.14);
28 --faint: rgba(227, 233, 240, 0.16); --focus: #6fa8e8;
29 }
30}
31:root[data-theme="dark"] {
32 color-scheme: dark;
33 --paper: #0f161f; --panel: #16202c; --ink: #e3e9f0; --muted: #93a3b5; --rule: #2a3747; --grid: #1f2a37;
34 --x: #6fa8e8; --y: #d57bae; --chaos: #e8b931; --chaos-wash: rgba(232, 185, 49, 0.14);
35 --faint: rgba(227, 233, 240, 0.16); --focus: #6fa8e8;
36}
37* { box-sizing: border-box; }
38body { background: var(--paper); color: var(--ink); font-family: var(--sans); font-size: 15px; line-height: 1.5; padding-inline: 16px; padding-block: 20px 48px; }
39.wrap { max-width: 1280px; margin: 0 auto; display: grid; gap: 20px; }
40header { display: grid; gap: 6px; }
41h1 { 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; }
42h1 .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%; }
43.lede { max-width: 74ch; color: var(--muted); margin: 0; }
44.layout { display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 20px; align-items: start; }
45@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
46aside { background: var(--panel); border: 1px solid var(--rule); border-radius: 6px; padding: 16px; display: grid; gap: 16px; position: sticky; top: calc(env(safe-area-inset-top, 0px) + 12px); }
47@media (max-width: 900px) { aside { position: static; } }
48.label { font-size: 11px; font-weight: 650; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }
49.presets { display: flex; flex-wrap: wrap; gap: 6px; }
50.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; }
51.chip:hover { border-color: var(--muted); }
52.field { display: grid; gap: 4px; }
53.field .row { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
54.field label { font-size: 14px; font-weight: 550; }
55.val { font-family: var(--mono); font-size: 13px; font-variant-numeric: tabular-nums; color: var(--ink); white-space: nowrap; }
56.hint { font-size: 12px; color: var(--muted); margin: 0; }
57input[type="range"] { width: 100%; accent-color: var(--x); }
58.player { display: grid; gap: 8px; }
59.btns { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
60.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; }
61.btn:hover { border-color: var(--muted); }
62.btn .sw { width: 10px; height: 10px; border-radius: 2px; flex: none; }
63.btn[aria-pressed="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }
64button:focus-visible, input:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
65.stats { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 13px; margin: 0; }
66.stats dt { color: var(--muted); }
67.stats dd { margin: 0; font-family: var(--mono); font-variant-numeric: tabular-nums; text-align: right; }
68.status { font-size: 12px; color: var(--muted); min-height: 1.5em; font-family: var(--mono); }
69main { display: grid; gap: 16px; min-width: 0; }
70.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
71@media (max-width: 640px) { .pair { grid-template-columns: 1fr; } }
72figure { margin: 0; display: grid; gap: 6px; min-width: 0; }
73figcaption { display: grid; gap: 2px; }
74figcaption b { font-weight: 650; font-size: 15px; }
75figcaption span { font-size: 13px; color: var(--muted); max-width: 80ch; }
76.plot { position: relative; width: 100%; background: var(--panel); border: 1px solid var(--rule); border-radius: 6px; }
77.plot canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
78.chainplot { aspect-ratio: 3.4 / 1; min-height: 190px; }
79.wide { aspect-ratio: 3.2 / 1; min-height: 170px; }
80.half { aspect-ratio: 1.6 / 1; min-height: 170px; }
81.short { aspect-ratio: 4.6 / 1; min-height: 140px; }
82.setuptext { display: grid; gap: 10px; font-size: 14px; grid-template-columns: minmax(0, 1fr) auto; align-items: start; }
83@media (max-width: 640px) { .setuptext { grid-template-columns: 1fr; } }
84.setuptext p { margin: 0; max-width: 80ch; }
85.k { font-family: var(--mono); font-size: 13px; white-space: nowrap; }
86.strikes { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
87.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; }
88.sk .sw { width: 8px; height: 8px; border-radius: 2px; }
89.sk[aria-pressed="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }
90.sk:disabled { opacity: 0.4; cursor: default; }
91.strikes .btn { padding: 4px 12px; font-size: 13px; margin-left: auto; }
92.legend { display: flex; flex-wrap: wrap; gap: 14px; font-size: 12px; color: var(--muted); }
93.legend i { display: inline-block; width: 18px; height: 3px; vertical-align: middle; margin-right: 6px; border-radius: 2px; }
94footer { color: var(--muted); font-size: 13px; max-width: 80ch; }
95footer p { margin: 0 0 8px; }
96</style>
98<div class="wrap">
99 <header>
100 <h1>Struck FPUT Chain
101 <span class="eq">f(Δ) = Δ + βΔ³ between neighbours, β = 1, walls at both ends</span>
102 </h1>
103 <p class="lede">A row of masses joined by springs that stiffen as they stretch, hit near one end and left to ring down: the many-mode version of the coupled-oscillator toy. A gentle strike keeps its energy in the few modes it started in. A strong one spreads it over all of them, chaotically, until damping takes the energy back below the threshold, and the chain then rings on with whatever spectrum the chaos left behind. Strikes A and B differ by a tiny change in strength.</p>
104 </header>
106 <div class="layout">
107 <aside aria-label="Controls">
108 <div class="player">
109 <div class="label">Listen</div>
110 <div class="btns">
111 <button class="btn" id="playA" type="button"><span class="sw" style="background: var(--x)"></span>Strike A</button>
112 <button class="btn" id="playB" type="button"><span class="sw" style="background: var(--y)"></span>Strike B</button>
113 <button class="btn" id="playAB" type="button">A, then B</button>
114 <button class="btn" id="stop" type="button">Stop</button>
115 </div>
116 <div class="status" id="status">Computing…</div>
117 </div>
119 <div class="field">
120 <div class="label">Presets</div>
121 <div class="presets">
122 <button class="chip" type="button" data-preset="gentle">Gentle</button>
123 <button class="chip" type="button" data-preset="cascade">Cascade only</button>
124 <button class="chip" type="button" data-preset="strong">Strong</button>
125 <button class="chip" type="button" data-preset="detuned">Strong, detuned</button>
126 </div>
127 </div>
129 <div class="field">
130 <div class="row"><label for="E0">Strike energy E</label><span class="val" id="E0v"></span></div>
131 <input id="E0" type="range" min="0" max="1000" step="1">
132 <p class="hint">Around E ≈ 5 energy spreads over most modes for a while, but the spreading is too brief to amplify a tiny difference between strikes; by E ≈ 30 it is (try a larger A/B difference at E ≈ 5).</p>
133 </div>
134 <div class="field">
135 <div class="row"><label for="N">Masses</label><span class="val" id="Nv"></span></div>
136 <input id="N" type="range" min="8" max="64" step="1">
137 </div>
138 <div class="field">
139 <div class="row"><label for="mu">On-site springs μ</label><span class="val" id="muv"></span></div>
140 <input id="mu" type="range" min="0" max="2" step="0.05">
141 <p class="hint">Springs from each mass to the frame detune the modes (as in a plate) and hold the cascade back: at μ = 1 even E = 30 stays put.</p>
142 </div>
143 <div class="field">
144 <div class="row"><label for="pos">Strike position</label><span class="val" id="posv"></span></div>
145 <input id="pos" type="range" min="0.05" max="0.5" step="0.01">
146 </div>
147 <div class="field">
148 <div class="row"><label for="wid">Strike width</label><span class="val" id="widv"></span></div>
149 <input id="wid" type="range" min="0.6" max="6" step="0.1">
150 </div>
151 <div class="field">
152 <div class="row"><label for="dl">Difference between A and B</label><span class="val" id="dlv"></span></div>
153 <input id="dl" type="range" min="-14" max="-2" step="0.5">
154 </div>
155 <div class="field">
156 <div class="row"><label for="tlo">Ring time, lowest mode</label><span class="val" id="tlov"></span></div>
157 <input id="tlo" type="range" min="0.5" max="10" step="0.1">
158 </div>
159 <div class="field">
160 <div class="row"><label for="thi">Ring time, highest mode</label><span class="val" id="thiv"></span></div>
161 <input id="thi" type="range" min="0.05" max="5" step="0.05">
162 <p class="hint">Friction on the masses and dashpots between them: high modes die faster.</p>
163 </div>
164 <div class="field">
165 <div class="row"><label for="f1">Pitch of the lowest mode</label><span class="val" id="f1v"></span></div>
166 <input id="f1" type="range" min="40" max="440" step="1">
167 </div>
168 <div class="field">
169 <div class="row"><label for="dur">Length</label><span class="val" id="durv"></span></div>
170 <input id="dur" type="range" min="1" max="6" step="0.5">
171 </div>
173 <dl class="stats" aria-live="polite">
174 <dt>Chaotic phase ends</dt><dd id="sChaos">–</dd>
175 <dt>Peak separation rate</dt><dd id="sLam">–</dd>
176 <dt>Modes sharing the energy, peak</dt><dd id="sNeff">–</dd>
177 <dt>Final spectra, A vs 8 strikes</dt><dd id="sSpread">–</dd>
178 </dl>
179 </aside>
181 <main>
182 <figure>
183 <figcaption><b>Physical setup</b><span>Masses on a line between two walls, each joined to the next by a spring and a dashpot. The masses move along the line; here each mass's displacement is drawn sideways so the waves are visible.</span></figcaption>
184 <div class="plot chainplot"><canvas id="cChain"></canvas></div>
185 <div class="setuptext">
186 <p>The springs push back with f(Δ) = Δ + βΔ³ when stretched by Δ, so each one stiffens as it stretches: its stiffness is <span class="k">1 + 3βΔ²</span> (line thickness). That is what couples the modes, exactly as stretching the midplane couples the modes of a plate. The strike gives the marked masses a sudden velocity; the sound is the velocity of the mass at the pickup ring.</p>
187 <button class="btn" id="slow" type="button" aria-pressed="false">Slow motion</button>
188 </div>
189 </figure>
191 <figure>
192 <figcaption><b>Where the energy is</b><span>Share of strike A's energy in each normal mode over time (log scale, lowest mode at the bottom). A cascade shows as energy climbing to the higher modes.</span></figcaption>
193 <div class="plot wide"><canvas id="cModes"></canvas><canvas id="cModesO"></canvas></div>
194 </figure>
196 <div class="pair">
197 <figure>
198 <figcaption><b>How many modes share it</b><span>Effective fraction of modes holding the energy (1 for equal shares; about 0.7 for random equipartition), for 8 strikes. Shaded: the chaotic phase of strike A.</span></figcaption>
199 <div class="plot half"><canvas id="cNeff"></canvas><canvas id="cNeffO"></canvas></div>
200 </figure>
201 <figure>
202 <figcaption><b>The spectrum it rings with</b><span>Each strike's final share of energy per mode. Where the strikes differ, the fingerprint is chaos's doing.</span></figcaption>
203 <div class="plot half"><canvas id="cFinal"></canvas></div>
204 </figure>
205 </div>
206 <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>
208 <figure>
209 <figcaption><b>How fast nearby strikes separate</b><span>Local Lyapunov exponent of strike A (per second). The dashed line is the energy in dB.</span></figcaption>
210 <div class="plot short"><canvas id="cLam"></canvas><canvas id="cLamO"></canvas></div>
211 </figure>
213 <figure>
214 <figcaption><b>What you hear</b><span>Spectrogram of the selected strike at the pickup. Click a strike, or use the ← and → keys, to flip between them; the color scale is shared, so differences in level are real. The selected strike is also drawn bold in the two plots above.</span></figcaption>
215 <div class="strikes" role="group" aria-label="Choose a strike" id="strikeSel"></div>
216 <div class="plot wide"><canvas id="cSpec"></canvas><canvas id="cSpecO"></canvas></div>
217 </figure>
219 <footer>
220 <p>Integrated in your browser with a fourth-order symplectic scheme (Yoshida); the damping is applied once per audio sample through its exact propagator, which the chain's sine modes diagonalize. Time is scaled so that the lowest mode sounds at the chosen pitch. Fermi, Pasta, Ulam and Tsingou (1955) expected such a chain to share its energy among all modes and found that at low energy it does not; above a threshold it does, and the sharing is chaotic.</p>
221 </footer>
222 </main>
223 </div>
224</div>
226<script id="worker-src" type="text/plain">
227const C = Math.cbrt(2), YS = [1 / (2 - C), -C / (2 - C), 1 / (2 - C)];
229function setupChain(p) {
230 const N = p.N, S = new Float64Array(N * N), om = new Float64Array(N), lap = new Float64Array(N);
231 const c = Math.sqrt(2 / (N + 1));
232 for (let j = 0; j < N; j++) for (let k = 0; k < N; k++) S[j * N + k] = c * Math.sin((j + 1) * (k + 1) * Math.PI / (N + 1));
233 for (let k = 0; k < N; k++) { lap[k] = 4 * Math.sin((k + 1) * Math.PI / (2 * (N + 1))) ** 2; om[k] = Math.sqrt(p.mu * p.mu + lap[k]); }
234 // damping: amplitude decay of mode k = (c0 + c1 lap_k) / 2; propagator over one output sample
235 const P = new Float64Array(N * N);
236 const ex = new Float64Array(N);
237 for (let k = 0; k < N; k++) ex[k] = Math.exp(-p.dtOut * (p.c0 + p.c1 * lap[k]));
238 for (let i = 0; i < N; i++) for (let j = 0; j < N; j++) {
239 let s = 0;
240 for (let k = 0; k < N; k++) s += S[i * N + k] * ex[k] * S[j * N + k];
241 P[i * N + j] = s;
242 }
243 return { S, om, P };
244}
246function strikeRun(p, ch, scale, opts) {
247 const N = p.N, mu2 = p.mu * p.mu, beta = 1;
248 const u = new Float64Array(N), v = new Float64Array(N), f = new Float64Array(N);
249 const du = new Float64Array(N), dv = new Float64Array(N), g = new Float64Array(N);
250 const tmp = new Float64Array(N);
251 // strike profile
252 let n2 = 0;
253 for (let j = 0; j < N; j++) { const w = Math.exp(-0.5 * ((j + 1 - p.center) / p.width) ** 2); v[j] = w; n2 += w * w; }
254 const amp = Math.sqrt(2 * p.E0 / n2) * scale;
255 for (let j = 0; j < N; j++) v[j] *= amp;
256 if (opts.tangent) { for (let j = 0; j < N; j++) { du[j] = Math.sin(1.7 * j + 0.3); dv[j] = Math.cos(2.3 * j + 0.1); } normalize(du, dv); }
257 const nsub = Math.max(1, Math.ceil(p.dtOut / p.hmax)), h0 = p.dtOut / nsub;
258 const nOut = p.nOut, K = p.decim, nD = Math.floor(nOut / K);
259 const sig = opts.audio ? new Float32Array(nOut) : null;
260 const snapN = opts.snap ? Math.min(nOut, p.snapN) : 0;
261 const snap = opts.snap ? new Float32Array(snapN * N) : null;
262 const shares = opts.modes ? new Float32Array(nD * N) : null;
263 const neff = new Float32Array(nD);
264 const energy = new Float32Array(nD);
265 const logsum = opts.tangent ? new Float64Array(nD) : null;
266 const finalShare = new Float32Array(N);
267 let L = 0;
268 const pk = p.pickup - 1;
270 function force(uu, out) {
271 let dPrev = uu[0], fPrev = dPrev + beta * dPrev * dPrev * dPrev; // left wall: stretch u_1 - 0
272 for (let j = 0; j < N; j++) {
273 const d = (j < N - 1 ? uu[j + 1] : 0) - uu[j];
274 const fn = d + beta * d * d * d;
275 out[j] = fn - fPrev - mu2 * uu[j];
276 fPrev = fn;
277 }
278 }
279 function tforce(uu, dd, out) {
280 let sPrev = (1 + 3 * beta * uu[0] * uu[0]) * dd[0];
281 for (let j = 0; j < N; j++) {
282 const d = (j < N - 1 ? uu[j + 1] : 0) - uu[j];
283 const dl = (j < N - 1 ? dd[j + 1] : 0) - dd[j];
284 const sn = (1 + 3 * beta * d * d) * dl;
285 out[j] = sn - sPrev - mu2 * dd[j];
286 sPrev = sn;
287 }
288 }
289 function normalize(a, b) {
290 let s = 0;
291 for (let j = 0; j < N; j++) s += a[j] * a[j] + b[j] * b[j];
292 const n = Math.sqrt(s);
293 for (let j = 0; j < N; j++) { a[j] /= n; b[j] /= n; }
294 return n;
295 }
296 function modeShares(out) {
297 let tot = 0;
298 for (let k = 0; k < N; k++) {
299 let q = 0, pp = 0;
300 for (let j = 0; j < N; j++) { const s = ch.S[j * N + k]; q += s * u[j]; pp += s * v[j]; }
301 const e = 0.5 * (pp * pp + ch.om[k] * ch.om[k] * q * q);
302 out[k] = e; tot += e;
303 }
304 for (let k = 0; k < N; k++) out[k] /= (tot || 1);
305 return tot;
306 }
307 const es = new Float64Array(N);
308 force(u, f);
309 if (opts.tangent) tforce(u, du, g);
310 for (let i = 0; i < nOut; i++) {
311 for (let s = 0; s < nsub; s++) {
312 for (let c = 0; c < 3; c++) {
313 const h = YS[c] * h0;
314 for (let j = 0; j < N; j++) v[j] += 0.5 * h * f[j];
315 if (opts.tangent) { for (let j = 0; j < N; j++) { dv[j] += 0.5 * h * g[j]; du[j] += h * dv[j]; } }
316 for (let j = 0; j < N; j++) u[j] += h * v[j];
317 force(u, f);
318 for (let j = 0; j < N; j++) v[j] += 0.5 * h * f[j];
319 if (opts.tangent) { tforce(u, du, g); for (let j = 0; j < N; j++) dv[j] += 0.5 * h * g[j]; }
320 }
321 }
322 // damping over one output sample (exact propagator)
323 for (let a = 0; a < N; a++) { let s = 0; const row = a * N; for (let b = 0; b < N; b++) s += ch.P[row + b] * v[b]; tmp[a] = s; }
324 v.set(tmp);
325 if (opts.tangent) {
326 for (let a = 0; a < N; a++) { let s = 0; const row = a * N; for (let b = 0; b < N; b++) s += ch.P[row + b] * dv[b]; tmp[a] = s; }
327 dv.set(tmp);
328 L += Math.log(normalize(du, dv));
329 tforce(u, du, g);
330 }
331 if (sig) sig[i] = v[pk];
332 if (i < snapN) for (let j = 0; j < N; j++) snap[i * N + j] = u[j];
333 if ((i + 1) % K === 0) {
334 const jd = (i + 1) / K - 1;
335 if (jd < nD) {
336 const tot = modeShares(es);
337 let S = 0;
338 for (let k = 0; k < N; k++) if (es[k] > 0) S -= es[k] * Math.log(es[k]);
339 neff[jd] = Math.exp(S) / N;
340 energy[jd] = tot;
341 if (shares) for (let k = 0; k < N; k++) shares[jd * N + k] = es[k];
342 if (logsum) logsum[jd] = L;
343 }
344 }
345 }
346 modeShares(es);
347 for (let k = 0; k < N; k++) finalShare[k] = es[k];
348 return { sig, snap, shares, neff, energy, logsum, finalShare };
349}
351onmessage = (ev) => {
352 const { gen, p } = ev.data;
353 const ch = setupChain(p);
354 postMessage({ kind: "progress", gen, text: "Computing strike A…" });
355 const a = strikeRun(p, ch, 1, { audio: true, snap: true, modes: true, tangent: true });
356 postMessage({ kind: "progress", gen, text: "Computing strike B…" });
357 const b = strikeRun(p, ch, 1 + p.delta, { audio: true, snap: false, modes: false, tangent: false });
358 postMessage({ kind: "strikes", gen, a, b, om: ch.om });
359 for (let k = 2; k < 8; k++) {
360 postMessage({ kind: "progress", gen, text: "Computing strike " + (k + 1) + " of 8…" });
361 const r = strikeRun(p, ch, 1 + k * p.delta, { audio: true, snap: false, modes: false, tangent: false });
362 postMessage({ kind: "member", gen, k, neff: r.neff, finalShare: r.finalShare, sig: r.sig }, [r.sig.buffer]);
363 }
364 postMessage({ kind: "done", gen });
365};
366</script>
368<script>
369(() => {
370 const SR = 44100;
371 const $ = (id) => document.getElementById(id);
372 const css = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
373 const fmt = (v, d = 2) => Number(v).toFixed(d);
375 const PRESETS = {
376 gentle: { E0: 0.5, N: 32, mu: 0, pos: 0.25, wid: 2, dl: -10, tlo: 3, thi: 0.3, f1: 110, dur: 4 },
377 cascade: { E0: 5, N: 32, mu: 0, pos: 0.25, wid: 2, dl: -10, tlo: 3, thi: 0.3, f1: 110, dur: 4 },
378 strong: { E0: 30, N: 32, mu: 0, pos: 0.25, wid: 2, dl: -10, tlo: 3, thi: 0.3, f1: 110, dur: 4 },
379 detuned: { E0: 30, N: 32, mu: 1, pos: 0.25, wid: 2, dl: -10, tlo: 3, thi: 0.3, f1: 110, dur: 4 },
380 };
381 const eFromSlider = (v) => Math.pow(10, -1.3 + 3.3 * v / 1000);
382 const sliderFromE = (E) => Math.round((Math.log10(E) + 1.3) / 3.3 * 1000);
384 function readParams() {
385 const E0 = eFromSlider(+$("E0").value), N = +$("N").value, mu = +$("mu").value;
386 const pos = +$("pos").value, wid = +$("wid").value, dl = +$("dl").value;
387 const tlo = +$("tlo").value, thi = Math.min(+$("thi").value, tlo), f1 = +$("f1").value, dur = +$("dur").value;
388 const center = Math.max(1, Math.round(pos * (N + 1)));
389 $("E0v").textContent = (E0 < 10 ? fmt(E0, 2) : fmt(E0, 1)) + " (" + fmt(E0 / N, 3) + " per mass)";
390 $("Nv").textContent = String(N);
391 $("muv").textContent = fmt(mu, 2);
392 $("posv").textContent = "mass " + center + " of " + N;
393 $("widv").textContent = fmt(wid, 1) + " masses";
394 $("dlv").textContent = "1e" + dl + " in strength";
395 $("tlov").textContent = fmt(tlo, 1) + " s";
396 $("thiv").textContent = fmt(thi, 2) + " s";
397 const lap1 = 4 * Math.sin(Math.PI / (2 * (N + 1))) ** 2, lapN = 4 * Math.sin(N * Math.PI / (2 * (N + 1))) ** 2;
398 const om1 = Math.sqrt(mu * mu + lap1), omN = Math.sqrt(mu * mu + lapN);
399 $("f1v").textContent = Math.round(f1) + " Hz (top: " + Math.round(f1 * omN / om1) + " Hz)";
400 $("durv").textContent = fmt(dur, 1) + " s";
401 const tau = om1 / (2 * Math.PI * f1); // seconds per time unit
402 // amplitude decay rates per time unit, then c0, c1 from (c0 + c1 lap) / 2
403 const sLo = Math.log(1000) / tlo * tau, sHi = Math.log(1000) / thi * tau;
404 const c1 = 2 * (sHi - sLo) / (lapN - lap1), c0 = Math.max(0, 2 * sLo - c1 * lap1);
405 const nOut = Math.round(dur * SR);
406 return {
407 E0, N, mu, center, width: wid, delta: Math.pow(10, dl), tau, c0, c1, dur, f1, omN, om1,
408 dtOut: 1 / SR / tau, nOut, decim: Math.round(SR / 400), pickup: Math.max(1, Math.round(0.38 * (N + 1))),
409 hmax: Math.min(0.2, 0.45 / Math.sqrt(4 + mu * mu + 12 * E0)), snapN: Math.round(0.12 * SR),
410 };
411 }
412 function setControls(p) {
413 $("E0").value = sliderFromE(p.E0); $("N").value = p.N; $("mu").value = p.mu; $("pos").value = p.pos; $("wid").value = p.wid;
414 $("dl").value = p.dl; $("tlo").value = p.tlo; $("thi").value = p.thi; $("f1").value = p.f1; $("dur").value = p.dur;
415 }
417 // ---------------- worker ----------------
418 const workerURL = URL.createObjectURL(new Blob([$("worker-src").textContent], { type: "text/javascript" }));
419 let worker = null, gen = 0, params = null, A = null, B = null, OM = null, members = [];
420 let sel = 0, audioGain = 1;
421 const strikeAt = (k) => (k === 0 ? A : k === 1 ? B : members[k] || null);
422 const strikeColor = (k) => (k === 0 ? css("--x") : k === 1 ? css("--y") : css("--ink"));
423 const strikeName = (k) => (k === 0 ? "A" : k === 1 ? "B" : String(k + 1));
425 function run() {
426 stopAudio(); stopSlow(true);
427 params = readParams();
428 gen++;
429 if (worker) worker.terminate();
430 worker = new Worker(workerURL);
431 worker.onmessage = onResult;
432 A = B = null; members = []; bufs = [];
433 $("status").textContent = "Computing…";
434 worker.postMessage({ gen, p: params });
435 drawAll();
436 }
437 function onResult(ev) {
438 const m = ev.data;
439 if (m.gen !== gen) return;
440 if (m.kind === "progress") { $("status").textContent = m.text; return; }
441 if (m.kind === "strikes") {
442 A = m.a; B = m.b; OM = m.om;
443 A.lam = localLyap(A.logsum);
444 prepareAudio();
445 } else if (m.kind === "member") {
446 members[m.k] = m;
447 m.audio = scaleAudio(m.sig);
448 } else if (m.kind === "done") {
449 $("status").textContent = "Ready. Strikes differ by 1e" + $("dl").value + " in strength.";
450 }
451 updateStats();
452 renderSelector();
453 drawAll();
454 }
456 function renderSelector() {
457 const box = $("strikeSel");
458 if (!box.childElementCount) {
459 for (let k = 0; k < 8; k++) {
460 const b = document.createElement("button");
461 b.type = "button"; b.className = "sk"; b.dataset.k = k; b.id = "sk" + k;
462 b.innerHTML = '<span class="sw"></span>' + strikeName(k);
463 b.addEventListener("click", () => selectStrike(k));
464 box.appendChild(b);
465 }
466 const pb = document.createElement("button");
467 pb.type = "button"; pb.className = "btn"; pb.id = "playSel"; pb.textContent = "Play this strike";
468 pb.addEventListener("click", () => play(sel, null));
469 box.appendChild(pb);
470 }
471 for (let k = 0; k < 8; k++) {
472 const b = $("sk" + k);
473 b.disabled = !strikeAt(k);
474 b.setAttribute("aria-pressed", String(k === sel));
475 b.querySelector(".sw").style.background = strikeColor(k);
476 b.title = strikeAt(k) ? "Strike " + strikeName(k) + ": strength × (1 + " + k + " × 1e" + $("dl").value + ")" : "computing…";
477 }
478 $("playSel").disabled = !strikeAt(sel);
479 }
480 function selectStrike(k) {
481 if (!strikeAt(k)) return;
482 sel = k;
483 renderSelector();
484 drawNeff(); drawFinal(); drawSpec();
485 }
486 document.addEventListener("keydown", (e) => {
487 if (e.target && (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")) return;
488 if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
489 const dir = e.key === "ArrowRight" ? 1 : -1;
490 for (let i = 1; i <= 8; i++) {
491 const k = (sel + dir * i + 8) % 8;
492 if (strikeAt(k)) { selectStrike(k); e.preventDefault(); return; }
493 }
494 });
496 function localLyap(logsum) {
497 const dtD = params.decim * params.dtOut;
498 const w = Math.max(1, Math.round(200 / dtD)); // windows of 200 time units
499 const out = new Float32Array(logsum.length);
500 for (let i = 0; i < logsum.length; i++) {
501 const j = Math.max(0, i - w);
502 out[i] = i > j ? (logsum[i] - logsum[j]) / ((i - j) * dtD) / params.tau : NaN;
503 }
504 return out;
505 }
506 function chaosEnd(lam) {
507 const thr = 0.005 / params.tau;
508 let last = -1;
509 for (let i = 0; i < lam.length; i++) if (lam[i] > thr) last = i;
510 return last < 0 ? null : (last + 1) * params.decim / SR;
511 }
512 function updateStats() {
513 if (!A) { for (const id of ["sChaos", "sLam", "sNeff", "sSpread"]) $(id).textContent = "–"; return; }
514 const end = chaosEnd(A.lam);
515 $("sChaos").textContent = end === null ? "never chaotic" : end > params.dur - 0.15 ? "still chaotic at the end" : fmt(end, 2) + " s";
516 let peak = 0; for (const v of A.lam) if (v > peak) peak = v;
517 $("sLam").textContent = peak > 0.005 / params.tau ? fmt(peak, 0) + " /s" : "≈ 0";
518 let nmax = 0; for (const v of A.neff) nmax = Math.max(nmax, v);
519 $("sNeff").textContent = fmt(nmax * 100, 0) + "% of " + params.N;
520 // spread of final spectra: median over modes holding > 0.1% of the std of log10 share (decades)
521 const all = [A.finalShare, B.finalShare];
522 for (let k = 2; k < 8; k++) if (members[k]) all.push(members[k].finalShare);
523 if (all.length < 8) { $("sSpread").textContent = "computing…"; return; }
524 const N = params.N, sd = [];
525 for (let k = 0; k < N; k++) {
526 const vals = all.map((s) => Math.log10(Math.max(s[k], 1e-12)));
527 const meanShare = all.reduce((s, x) => s + x[k], 0) / all.length;
528 if (meanShare < 1e-3) continue;
529 const m = vals.reduce((s, v) => s + v, 0) / vals.length;
530 sd.push(Math.sqrt(vals.reduce((s, v) => s + (v - m) ** 2, 0) / vals.length));
531 }
532 sd.sort((a, b) => a - b);
533 const med = sd.length ? sd[Math.floor(sd.length / 2)] : 0;
534 $("sSpread").textContent = med < 0.01 ? "identical" : "differ by " + fmt(10 * med, 1) + " dB";
535 }
537 // ---------------- canvas helpers ----------------
538 function setup(canvas) {
539 const r = canvas.getBoundingClientRect();
540 const dpr = window.devicePixelRatio || 1;
541 const w = Math.max(1, Math.round(r.width * dpr)), h = Math.max(1, Math.round(r.height * dpr));
542 if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
543 const ctx = canvas.getContext("2d");
544 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
545 ctx.clearRect(0, 0, r.width, r.height);
546 return { ctx, W: r.width, H: r.height };
547 }
548 const PAD = { l: 44, r: 12, t: 10, b: 26 };
549 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; }
550 function axesTime(ctx, W, H, ymin, ymax, yticks, yfmt, dur) {
551 const x0 = PAD.l, x1 = W - PAD.r, y0 = PAD.t, y1 = H - PAD.b;
552 ctx.strokeStyle = css("--grid"); ctx.lineWidth = 1;
553 ctx.fillStyle = css("--muted"); ctx.font = "11px " + css("--mono");
554 ctx.textAlign = "right"; ctx.textBaseline = "middle";
555 for (const v of yticks) {
556 const y = y1 - (v - ymin) / (ymax - ymin) * (y1 - y0);
557 ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x1, y); ctx.stroke();
558 ctx.fillText(yfmt(v), x0 - 6, y);
559 }
560 ctx.textAlign = "center"; ctx.textBaseline = "top";
561 const step = dur <= 2 ? 0.25 : dur <= 5 ? 0.5 : 1;
562 for (let t = 0; t <= dur + 1e-9; t += step) {
563 const x = x0 + t / dur * (x1 - x0);
564 ctx.beginPath(); ctx.moveTo(x, y0); ctx.lineTo(x, y1); ctx.stroke();
565 ctx.fillText(fmt(t, step < 1 ? (step < 0.5 ? 2 : 1) : 0) + (t === 0 ? " s" : ""), x, y1 + 6);
566 }
567 return { x0, x1, y0, y1, X: (t) => x0 + t / dur * (x1 - x0), Y: (v) => y1 - (v - ymin) / (ymax - ymin) * (y1 - y0) };
568 }
569 function chaosSpan(ctx, ax) {
570 if (!A || !A.lam) return;
571 const end = chaosEnd(A.lam);
572 if (end === null) return;
573 ctx.fillStyle = css("--chaos-wash");
574 ctx.fillRect(ax.x0, ax.y0, ax.X(end) - ax.x0, ax.y1 - ax.y0);
575 }
576 function seriesTime(ctx, ax, arr, color, width, dash) {
577 if (!arr) return;
578 const dt = params.decim / SR;
579 ctx.strokeStyle = color; ctx.lineWidth = width; ctx.setLineDash(dash || []);
580 ctx.beginPath();
581 let started = false;
582 for (let i = 0; i < arr.length; i++) {
583 const v = arr[i];
584 if (!Number.isFinite(v)) { started = false; continue; }
585 const x = ax.X((i + 1) * dt), y = ax.Y(v);
586 if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);
587 }
588 ctx.stroke(); ctx.setLineDash([]);
589 }
590 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]; }
591 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]; }
592 function heatColor(v, bg, ink, hot) { return v < 0.72 ? mix(bg, ink, v / 0.72) : mix(ink, hot, (v - 0.72) / 0.28); }
594 // ---------------- chain (physical setup) ----------------
595 let smOn = false, smIdx = 0, smRaf = 0, smLast = 0;
596 function zigzag(ctx, x0, y0, x1, y1, coils, amp) {
597 const dx = x1 - x0, dy = y1 - y0, L = Math.hypot(dx, dy) || 1, ux = dx / L, uy = dy / L, nx = -uy, ny = ux;
598 const lead = Math.min(3, L * 0.15);
599 ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x0 + ux * lead, y0 + uy * lead);
600 const n = coils * 2, seg = (L - 2 * lead) / n;
601 for (let k = 1; k < n; k++) { const s = lead + k * seg, side = k % 2 ? 1 : -1; ctx.lineTo(x0 + ux * s + nx * amp * side, y0 + uy * s + ny * amp * side); }
602 ctx.lineTo(x1 - ux * lead, y1 - uy * lead); ctx.lineTo(x1, y1); ctx.stroke();
603 }
604 function drawChain() {
605 const { ctx, W, H } = setup($("cChain"));
606 if (!params) return;
607 const N = params.N, x0 = 26, x1 = W - 26, cy = H / 2 + 6;
608 const gap = (x1 - x0) / (N + 1);
609 const ink = css("--ink"), muted = css("--muted");
610 // walls
611 ctx.strokeStyle = muted; ctx.lineWidth = 2;
612 ctx.beginPath(); ctx.moveTo(x0, 16); ctx.lineTo(x0, H - 16); ctx.moveTo(x1, 16); ctx.lineTo(x1, H - 16); ctx.stroke();
613 ctx.lineWidth = 1; ctx.strokeStyle = css("--rule");
614 for (let y = 16; y < H - 16; y += 8) { ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x0 - 6, y + 6); ctx.moveTo(x1, y); ctx.lineTo(x1 + 6, y - 6); ctx.stroke(); }
615 // displacement
616 const u = new Float64Array(N);
617 let umax = 0;
618 if (A && A.snap) {
619 const nS = A.snap.length / N;
620 for (let i = 0; i < nS * N; i += 3) umax = Math.max(umax, Math.abs(A.snap[i]));
621 if (smOn) { const i = Math.min(nS - 1, Math.floor(smIdx)); for (let j = 0; j < N; j++) u[j] = A.snap[i * N + j]; }
622 }
623 const sc = umax > 0 ? (H / 2 - 26) / umax : 1;
624 const px = (j) => x0 + (j + 1) * gap, py = (j) => cy - u[j] * sc;
625 // springs, thickness by stiffness 1 + 3 beta Delta^2
626 ctx.strokeStyle = css("--x"); ctx.lineJoin = "round";
627 for (let j = -1; j < N; j++) {
628 const ua = j >= 0 ? u[j] : 0, ub = j < N - 1 ? u[j + 1] : 0;
629 const d = ub - ua, kst = 1 + 3 * d * d;
630 ctx.lineWidth = Math.min(5, 0.8 + 1.4 * Math.log(kst) + 0.6);
631 const xa = j >= 0 ? px(j) : x0, ya = j >= 0 ? py(j) : cy, xb = j < N - 1 ? px(j + 1) : x1, yb = j < N - 1 ? py(j + 1) : cy;
632 zigzag(ctx, xa, ya, xb, yb, Math.max(2, Math.round(gap / 7)), Math.min(4, gap / 6));
633 }
634 // masses
635 const r = Math.max(3, Math.min(8, gap * 0.28));
636 for (let j = 0; j < N; j++) {
637 ctx.fillStyle = ink; ctx.beginPath(); ctx.arc(px(j), py(j), r, 0, 2 * Math.PI); ctx.fill();
638 }
639 // pickup ring
640 const pk = params.pickup - 1;
641 ctx.strokeStyle = css("--y"); ctx.lineWidth = 2;
642 ctx.beginPath(); ctx.arc(px(pk), py(pk), r + 5, 0, 2 * Math.PI); ctx.stroke();
643 ctx.fillStyle = css("--y"); ctx.font = "11px " + css("--mono"); ctx.textAlign = "center"; ctx.textBaseline = "top";
644 ctx.fillText("pickup", px(pk), H - 18);
645 // strike arrows (velocity profile) when at rest
646 if (!smOn) {
647 ctx.strokeStyle = css("--chaos"); ctx.fillStyle = css("--chaos"); ctx.lineWidth = 2.5;
648 for (let j = 0; j < N; j++) {
649 const w = Math.exp(-0.5 * ((j + 1 - params.center) / params.width) ** 2);
650 if (w < 0.08) continue;
651 const L = 34 * w, X = px(j), Y0 = cy - r - 4;
652 ctx.beginPath(); ctx.moveTo(X, Y0); ctx.lineTo(X, Y0 - L); ctx.stroke();
653 ctx.beginPath(); ctx.moveTo(X, Y0 - L - 6); ctx.lineTo(X - 4, Y0 - L + 1); ctx.lineTo(X + 4, Y0 - L + 1); ctx.closePath(); ctx.fill();
654 }
655 ctx.fillStyle = ink; ctx.font = "600 12px " + css("--sans"); ctx.textAlign = "left"; ctx.textBaseline = "top";
656 ctx.fillText("strike, E = " + (params.E0 < 10 ? fmt(params.E0, 2) : fmt(params.E0, 1)), px(Math.max(0, params.center - 1)) + 12, 10);
657 } else if (A) {
658 ctx.fillStyle = muted; ctx.font = "11px " + css("--mono"); ctx.textAlign = "left"; ctx.textBaseline = "top";
659 ctx.fillText("t = " + fmt(smIdx / SR * 1000, 2) + " ms of sound (displacement drawn sideways, magnified)", x0 + 10, 8);
660 }
661 }
662 function smTick(now) {
663 if (!smOn || !A) return;
664 const dtReal = smLast ? Math.min(0.1, (now - smLast) / 1000) : 0;
665 smLast = now;
666 smIdx += 2.5 * dtReal / params.dtOut; // 2.5 time units per second
667 if (smIdx >= A.snap.length / params.N - 1) { stopSlow(); return; }
668 drawChain();
669 smRaf = requestAnimationFrame(smTick);
670 }
671 function startSlow() { if (!A) return; smOn = true; smIdx = 0; smLast = 0; $("slow").setAttribute("aria-pressed", "true"); $("slow").textContent = "Stop slow motion"; smRaf = requestAnimationFrame(smTick); }
672 function stopSlow(silent) { smOn = false; cancelAnimationFrame(smRaf); $("slow").setAttribute("aria-pressed", "false"); $("slow").textContent = "Slow motion"; if (!silent) drawChain(); }
674 // ---------------- plots ----------------
675 function drawModes() {
676 const { ctx, W, H } = setup($("cModes"));
677 const ax = axesTime(ctx, W, H, 0, 1, [], () => "", params.dur);
678 if (!A || !A.shares) return;
679 const N = params.N, nD = A.neff.length;
680 const off = document.createElement("canvas");
681 off.width = nD; off.height = N;
682 const octx = off.getContext("2d"), img = octx.createImageData(nD, N);
683 const bg = hexToRgb(css("--panel")), ink = hexToRgb(css("--ink")), hot = hexToRgb(css("--chaos"));
684 for (let i = 0; i < nD; i++) for (let k = 0; k < N; k++) {
685 const v = Math.min(1, Math.max(0, (Math.log10(Math.max(A.shares[i * N + k], 1e-12)) + 6) / 6));
686 const c = heatColor(v, bg, ink, hot);
687 const o = ((N - 1 - k) * nD + i) * 4;
688 img.data[o] = c[0]; img.data[o + 1] = c[1]; img.data[o + 2] = c[2]; img.data[o + 3] = 255;
689 }
690 octx.putImageData(img, 0, 0);
691 ctx.imageSmoothingEnabled = false;
692 ctx.drawImage(off, ax.x0, ax.y0, ax.x1 - ax.x0, ax.y1 - ax.y0);
693 ctx.fillStyle = css("--muted"); ctx.font = "11px " + css("--mono"); ctx.textAlign = "right"; ctx.textBaseline = "middle";
694 const step = N <= 16 ? 4 : N <= 40 ? 8 : 16;
695 for (let k = step; k <= N; k += step) ctx.fillText("k " + k, ax.x0 - 6, ax.y1 - (k - 0.5) / N * (ax.y1 - ax.y0));
696 ctx.fillText("k 1", ax.x0 - 6, ax.y1 - 0.5 / N * (ax.y1 - ax.y0));
697 const end = chaosEnd(A.lam);
698 if (end !== null) { ctx.strokeStyle = css("--chaos"); ctx.setLineDash([5, 4]); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(ax.X(end), ax.y0); ctx.lineTo(ax.X(end), ax.y1); ctx.stroke(); ctx.setLineDash([]); }
699 }
700 function drawNeff() {
701 const { ctx, W, H } = setup($("cNeff"));
702 const ax = axesTime(ctx, W, H, 0, 1, [0, 0.25, 0.5, 0.75, 1], (v) => fmt(v, 2), params.dur);
703 chaosSpan(ctx, ax);
704 for (let k = 2; k < 8; k++) if (members[k]) seriesTime(ctx, ax, members[k].neff, css("--faint"), 1);
705 if (B) seriesTime(ctx, ax, B.neff, css("--y"), 1.4);
706 if (A) seriesTime(ctx, ax, A.neff, css("--x"), 1.6);
707 const S = strikeAt(sel);
708 if (S) seriesTime(ctx, ax, S.neff, strikeColor(sel), 3);
709 }
710 function drawFinal() {
711 const { ctx, W, H } = setup($("cFinal"));
712 if (!params) return;
713 const N = params.N, x0 = PAD.l, x1 = W - PAD.r, y0 = PAD.t, y1 = H - PAD.b;
714 const lo = -8, hi = 0;
715 const X = (k) => x0 + (k - 1) / Math.max(1, N - 1) * (x1 - x0), Y = (v) => y1 - (v - lo) / (hi - lo) * (y1 - y0);
716 ctx.strokeStyle = css("--grid"); ctx.lineWidth = 1; ctx.fillStyle = css("--muted"); ctx.font = "11px " + css("--mono");
717 ctx.textAlign = "right"; ctx.textBaseline = "middle";
718 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)); }
719 ctx.textAlign = "center"; ctx.textBaseline = "top";
720 const step = N <= 16 ? 4 : N <= 40 ? 8 : 16;
721 for (let k = 1; k <= N; k += (k === 1 ? step - 1 : step)) ctx.fillText("k " + k, X(k), y1 + 6);
722 const line = (s, color, w) => {
723 if (!s) return;
724 ctx.strokeStyle = color; ctx.lineWidth = w; ctx.beginPath();
725 for (let k = 1; k <= N; k++) { const v = Math.max(lo, Math.log10(Math.max(s[k - 1], 1e-12))); if (k === 1) ctx.moveTo(X(k), Y(v)); else ctx.lineTo(X(k), Y(v)); }
726 ctx.stroke();
727 };
728 for (let k = 2; k < 8; k++) if (members[k]) line(members[k].finalShare, css("--faint"), 1);
729 if (B) line(B.finalShare, css("--y"), 1.4);
730 if (A) line(A.finalShare, css("--x"), 1.6);
731 const S = strikeAt(sel);
732 if (S) line(S.finalShare, strikeColor(sel), 3);
733 }
734 function drawLam() {
735 const { ctx, W, H } = setup($("cLam"));
736 if (!A || !A.lam) { axesTime(ctx, W, H, 0, 1, [0, 1], () => "", params.dur); return; }
737 let mx = 0; for (const v of A.lam) if (v > mx) mx = v;
738 mx = Math.max(mx * 1.1, 0.02 / params.tau);
739 const st = niceStep(mx / 3), ticks = [];
740 for (let v = 0; v <= mx; v += st) ticks.push(v);
741 const ax = axesTime(ctx, W, H, -0.15 * mx, mx, ticks, (v) => fmt(v, 0), params.dur);
742 chaosSpan(ctx, ax);
743 ctx.strokeStyle = css("--rule"); ctx.beginPath(); ctx.moveTo(ax.x0, ax.Y(0)); ctx.lineTo(ax.x1, ax.Y(0)); ctx.stroke();
744 const e = A.energy, e0 = e[0] || 1, eDb = new Float32Array(e.length);
745 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;
746 seriesTime(ctx, ax, eDb, css("--muted"), 1, [4, 4]);
747 seriesTime(ctx, ax, A.lam, css("--x"), 1.6);
748 }
750 // ---------------- spectrogram ----------------
751 let specFmax = 0;
752 function fft(re, im) {
753 const n = re.length;
754 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]]; } }
755 for (let len = 2; len <= n; len <<= 1) {
756 const ang = -2 * Math.PI / len, wr = Math.cos(ang), wi = Math.sin(ang);
757 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; } }
758 }
759 }
760 function spectrogramOf(S) {
761 if (S.spec) return S.spec;
762 const sig = S.sig, N = 2048, hop = 256, frames = Math.max(1, Math.floor((sig.length - N) / hop));
763 const bins = Math.floor(specFmax / (SR / N));
764 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));
765 const mag = new Float32Array(frames * bins), re = new Float64Array(N), im = new Float64Array(N);
766 let mx = -Infinity;
767 for (let f = 0; f < frames; f++) {
768 for (let i = 0; i < N; i++) { re[i] = sig[f * hop + i] * win[i]; im[i] = 0; }
769 fft(re, im);
770 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; }
771 }
772 S.spec = { mag, frames, bins, mx };
773 return S.spec;
774 }
775 function drawSpec() {
776 const { ctx, W, H } = setup($("cSpec"));
777 const ax = axesTime(ctx, W, H, 0, 1, [], () => "", params.dur);
778 const S = strikeAt(sel) || A;
779 if (!S || !S.sig) return;
780 const ref = spectrogramOf(A).mx;
781 const { mag, frames, bins } = spectrogramOf(S);
782 const mx = ref;
783 const off = document.createElement("canvas"); off.width = frames; off.height = bins;
784 const octx = off.getContext("2d"), img = octx.createImageData(frames, bins);
785 const bg = hexToRgb(css("--panel")), ink = hexToRgb(css("--ink")), hot = hexToRgb(css("--chaos"));
786 for (let f = 0; f < frames; f++) for (let b = 0; b < bins; b++) {
787 const v = Math.min(1, Math.max(0, (mag[f * bins + b] - (mx - 70)) / 70));
788 const c = heatColor(v, bg, ink, hot), o = ((bins - 1 - b) * frames + f) * 4;
789 img.data[o] = c[0]; img.data[o + 1] = c[1]; img.data[o + 2] = c[2]; img.data[o + 3] = 255;
790 }
791 octx.putImageData(img, 0, 0);
792 const t0 = 1024 / SR, t1 = (frames * 256 + 1024) / SR;
793 ctx.drawImage(off, ax.X(t0), ax.y0, ax.X(t1) - ax.X(t0), ax.y1 - ax.y0);
794 ctx.fillStyle = css("--muted"); ctx.font = "11px " + css("--mono"); ctx.textAlign = "right"; ctx.textBaseline = "middle";
795 const step = niceStep(specFmax / 4);
796 for (let f = 0; f <= specFmax; f += step) ctx.fillText(f >= 1000 ? fmt(f / 1000, f % 1000 ? 1 : 0) + "k" : String(f), ax.x0 - 6, ax.y1 - f / specFmax * (ax.y1 - ax.y0));
797 ctx.fillStyle = strikeColor(sel); ctx.font = "600 12px " + css("--sans"); ctx.textAlign = "right"; ctx.textBaseline = "top";
798 ctx.fillText("strike " + strikeName(sel), ax.x1 - 6, ax.y0 + 4);
799 }
801 function drawAll() {
802 if (!params) return;
803 drawChain(); drawModes(); drawNeff(); drawFinal(); drawLam(); drawSpec(); drawOverlays(null, null);
804 }
806 // ---------------- audio ----------------
807 let actx = null, bufs = [], src = null, playStart = 0, playing = null, raf = 0;
808 function scaleAudio(s) {
809 const fade = Math.round(0.01 * SR), o = new Float32Array(s.length);
810 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))); }
811 return o;
812 }
813 function prepareAudio() {
814 let m = 0;
815 for (const s of [A.sig, B.sig]) for (let i = 0; i < s.length; i++) m = Math.max(m, Math.abs(s[i]));
816 audioGain = 0.8 / (m || 1); // one gain for every strike, so loudness differences are real
817 A.audio = scaleAudio(A.sig); B.audio = scaleAudio(B.sig); bufs = [];
818 specFmax = Math.min(16000, Math.max(1500, 1.6 * params.f1 * params.omN / params.om1));
819 }
820 function ensureCtx() { if (!actx) actx = new (window.AudioContext || window.webkitAudioContext)(); if (actx.state === "suspended") actx.resume(); }
821 function toBuffer(d) { const b = actx.createBuffer(1, d.length, SR); b.copyToChannel(d, 0); return b; }
822 function stopAudio() {
823 if (src) { try { src.onended = null; src.stop(); } catch (e) {} src = null; }
824 playing = null; cancelAnimationFrame(raf);
825 for (const id of ["playA", "playB", "playAB", "playSel"]) if ($(id)) $(id).setAttribute("aria-pressed", "false");
826 drawOverlays(null, null);
827 }
828 function play(k, then) {
829 const S = strikeAt(k);
830 if (!S || !S.audio) return;
831 ensureCtx(); stopAudio();
832 if (!bufs[k]) bufs[k] = toBuffer(S.audio);
833 src = actx.createBufferSource(); src.buffer = bufs[k]; src.connect(actx.destination);
834 src.onended = () => { src = null; if (then !== null && then !== undefined) setTimeout(() => play(then, null), 350); else stopAudio(); };
835 playStart = actx.currentTime; playing = k; src.start();
836 if (k === 0) $("playA").setAttribute("aria-pressed", "true");
837 else if (k === 1) $("playB").setAttribute("aria-pressed", "true");
838 if (k === sel && $("playSel")) $("playSel").setAttribute("aria-pressed", "true");
839 if (then !== null && then !== undefined) $("playAB").setAttribute("aria-pressed", "true");
840 tick();
841 }
842 function tick() { if (playing === null) return; drawOverlays(actx.currentTime - playStart, playing); raf = requestAnimationFrame(tick); }
843 function drawOverlays(t, which) {
844 for (const [id, base] of [["cModesO", "cModes"], ["cNeffO", "cNeff"], ["cLamO", "cLam"], ["cSpecO", "cSpec"]]) {
845 const { ctx, W } = setup($(id));
846 if (t === null) continue;
847 const H = $(base).getBoundingClientRect().height;
848 const x = PAD.l + Math.min(t, params.dur) / params.dur * (W - PAD.r - PAD.l);
849 ctx.strokeStyle = strikeColor(which); ctx.lineWidth = 2;
850 ctx.beginPath(); ctx.moveTo(x, PAD.t); ctx.lineTo(x, H - PAD.b); ctx.stroke();
851 }
852 }
854 // ---------------- wiring ----------------
855 $("playA").addEventListener("click", () => play(0, null));
856 $("playB").addEventListener("click", () => play(1, null));
857 $("playAB").addEventListener("click", () => play(0, 1));
858 $("stop").addEventListener("click", stopAudio);
859 $("slow").addEventListener("click", () => (smOn ? stopSlow() : startSlow()));
860 let timer = 0;
861 for (const id of ["E0", "N", "mu", "pos", "wid", "dl", "tlo", "thi", "f1", "dur"]) {
862 $(id).addEventListener("input", () => { readParams(); clearTimeout(timer); timer = setTimeout(run, 300); });
863 }
864 document.querySelectorAll("[data-preset]").forEach((b) => b.addEventListener("click", () => { setControls(PRESETS[b.dataset.preset]); run(); }));
865 let rt = 0;
866 window.addEventListener("resize", () => { clearTimeout(rt); rt = setTimeout(drawAll, 120); });
867 try {
868 window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", drawAll);
869 new MutationObserver(drawAll).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
870 } catch (e) {}
872 setControls(PRESETS.strong);
873 run();
874})();
875</script>