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