da03127Two-oscillator chaos toy: model, experiments, figures, web appJeremy Magland 1<meta charset="utf-8">
2<title>Coupled Oscillator Chaos</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;
27 --panel: #16202c;
28 --ink: #e3e9f0;
29 --muted: #93a3b5;
30 --rule: #2a3747;
31 --grid: #1f2a37;
32 --x: #6fa8e8;
33 --y: #d57bae;
34 --chaos: #e8b931;
35 --chaos-wash: rgba(232, 185, 49, 0.14);
36 --faint: rgba(227, 233, 240, 0.16);
37 --focus: #6fa8e8;
38 }
39}
40:root[data-theme="dark"] {
41 color-scheme: dark;
42 --paper: #0f161f;
43 --panel: #16202c;
44 --ink: #e3e9f0;
45 --muted: #93a3b5;
46 --rule: #2a3747;
47 --grid: #1f2a37;
48 --x: #6fa8e8;
49 --y: #d57bae;
50 --chaos: #e8b931;
51 --chaos-wash: rgba(232, 185, 49, 0.14);
52 --faint: rgba(227, 233, 240, 0.16);
53 --focus: #6fa8e8;
54}
55* { box-sizing: border-box; }
56body {
57 background: var(--paper);
58 color: var(--ink);
59 font-family: var(--sans);
60 font-size: 15px;
61 line-height: 1.5;
62 padding-inline: 16px;
63 padding-block: 20px 48px;
64}
65.wrap { max-width: 1280px; margin: 0 auto; display: grid; gap: 20px; }
66header { display: grid; gap: 6px; }
67h1 {
68 font-size: clamp(26px, 4vw, 38px);
69 font-weight: 750;
70 font-stretch: 78%;
71 letter-spacing: -0.01em;
72 line-height: 1.05;
73 margin: 0;
74 text-wrap: balance;
75}
76h1 .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%; }
77.lede { max-width: 72ch; color: var(--muted); margin: 0; }
78.layout { display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 20px; align-items: start; }
79@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
80aside {
81 background: var(--panel);
82 border: 1px solid var(--rule);
83 border-radius: 6px;
84 padding: 16px;
85 display: grid;
f7cb0afWeb app: listener direction or sum, layout fix; link the gong-wire and fput-plate toysJeremy Magland 86 grid-template-columns: minmax(0, 1fr);
88 position: sticky;
89 top: calc(env(safe-area-inset-top, 0px) + 12px);
90}
91@media (max-width: 900px) { aside { position: static; } }
92.label {
93 font-size: 11px;
94 font-weight: 650;
95 letter-spacing: 0.08em;
96 text-transform: uppercase;
97 color: var(--muted);
98}
99.presets { display: flex; flex-wrap: wrap; gap: 6px; }
100.chip {
101 font: inherit;
102 font-size: 13px;
103 padding: 4px 10px;
104 border-radius: 999px;
105 border: 1px solid var(--rule);
106 background: var(--paper);
107 color: var(--ink);
108 cursor: pointer;
109}
110.chip:hover { border-color: var(--muted); }
f7cb0afWeb app: listener direction or sum, layout fix; link the gong-wire and fput-plate toysJeremy Magland 111.chip[aria-pressed="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }
112input[type="range"]:disabled { opacity: 0.4; }
da03127Two-oscillator chaos toy: model, experiments, figures, web appJeremy Magland 113.field { display: grid; gap: 4px; }
114.field .row { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
115.field label { font-size: 14px; font-weight: 550; }
116.val { font-family: var(--mono); font-size: 13px; font-variant-numeric: tabular-nums; color: var(--ink); white-space: nowrap; }
117.hint { font-size: 12px; color: var(--muted); margin: 0; }
118input[type="range"] { width: 100%; accent-color: var(--x); }
119.player { display: grid; gap: 8px; }
120.btns { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
121.btn {
122 font: inherit;
123 font-weight: 600;
124 font-size: 14px;
125 padding: 9px 10px;
126 border-radius: 6px;
127 border: 1px solid var(--rule);
128 background: var(--paper);
129 color: var(--ink);
130 cursor: pointer;
131 display: flex;
132 align-items: center;
133 justify-content: center;
134 gap: 8px;
135}
136.btn:hover { border-color: var(--muted); }
137.btn .sw { width: 10px; height: 10px; border-radius: 2px; flex: none; }
138.btn.wide { grid-column: 1 / -1; }
139.btn[aria-pressed="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }
140button:focus-visible, input:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
141.stats { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 13px; margin: 0; }
142.stats dt { color: var(--muted); }
143.stats dd { margin: 0; font-family: var(--mono); font-variant-numeric: tabular-nums; text-align: right; }
144.status { font-size: 12px; color: var(--muted); min-height: 1.5em; font-family: var(--mono); }
145main { display: grid; gap: 16px; min-width: 0; }
146.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
147@media (max-width: 640px) { .pair { grid-template-columns: 1fr; } }
148figure { margin: 0; display: grid; gap: 6px; min-width: 0; }
149figcaption { display: grid; gap: 2px; }
150figcaption b { font-weight: 650; font-size: 15px; }
151figcaption span { font-size: 13px; color: var(--muted); max-width: 80ch; }
152.plot { position: relative; width: 100%; background: var(--panel); border: 1px solid var(--rule); border-radius: 6px; }
153.plot canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
154.sq { aspect-ratio: 1 / 1; max-width: 100%; }
155.wide { aspect-ratio: 3.2 / 1; min-height: 170px; }
156.short { aspect-ratio: 4.6 / 1; min-height: 140px; }
157.setup { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 16px; align-items: start; }
158@media (max-width: 760px) { .setup { grid-template-columns: 1fr; } }
159.setupplot { aspect-ratio: 1.45 / 1; }
160.setuptext { display: grid; gap: 10px; font-size: 14px; }
161.setuptext p { margin: 0; max-width: 60ch; }
162.setuptext .k { font-family: var(--mono); font-size: 13px; }
163.setuptext .btn { justify-self: start; }
164.strikes { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
165.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; }
166.sk .sw { width: 8px; height: 8px; border-radius: 2px; }
167.sk[aria-pressed="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }
168.sk:disabled { opacity: 0.4; cursor: default; }
169.strikes .btn { padding: 4px 12px; font-size: 13px; margin-left: auto; }
170.legend { display: flex; flex-wrap: wrap; gap: 14px; font-size: 12px; color: var(--muted); }
171.legend i { display: inline-block; width: 18px; height: 3px; vertical-align: middle; margin-right: 6px; border-radius: 2px; }
172footer { color: var(--muted); font-size: 13px; max-width: 80ch; }
173footer p { margin: 0 0 8px; }
174@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto ; } }
f7cb0afWeb app: listener direction or sum, layout fix; link the gong-wire and fput-plate toysJeremy Magland 175/* keep the control column inside its panel whatever the readouts say */
176aside > * { min-width: 0; }
177aside .field .row { flex-wrap: wrap; }
178aside .val { white-space: normal; text-align: right; overflow-wrap: anywhere; }
181<div class="wrap">
182 <header>
183 <h1>Coupled Oscillator Chaos
184 <span class="eq">H = ½(p<sub>x</sub>² + p<sub>y</sub>²) + ½(x² + ω²y²) + x²y²</span>
185 </h1>
186 <p class="lede">One mass on springs that can swing two ways, x and y, which stiffen each other as they move: two oscillators with a quartic coupling, struck once and left to ring down. A gentle strike rings regularly; a strong one goes chaotic until damping takes the energy back below the threshold, and then it rings on with whatever split of energy the chaos left behind. Strikes A and B differ by a tiny change in direction. Listen to both.</p>
187 </header>
189 <div class="layout">
190 <aside aria-label="Controls">
191 <div class="player">
192 <div class="label">Listen</div>
193 <div class="btns">
194 <button class="btn" id="playA" type="button"><span class="sw" style="background: var(--x)"></span>Strike A</button>
195 <button class="btn" id="playB" type="button"><span class="sw" style="background: var(--y)"></span>Strike B</button>
196 <button class="btn" id="playAB" type="button">A, then B</button>
197 <button class="btn" id="stop" type="button">Stop</button>
198 </div>
199 <div class="status" id="status">Computing…</div>
200 </div>
f7cb0afWeb app: listener direction or sum, layout fix; link the gong-wire and fput-plate toysJeremy Magland 202 <div class="field">
203 <div class="row"><label for="lis">Listen from</label><span class="val" id="lisv"></span></div>
204 <div class="presets" role="group" aria-label="Listener">
205 <button class="chip" type="button" id="lisOne" aria-pressed="false">One direction</button>
206 <button class="chip" type="button" id="lisSum" aria-pressed="true">Both (x + y)</button>
207 </div>
208 <input id="lis" type="range" min="0" max="90" step="1" value="45">
209 <p class="hint">A listener at angle θ hears the mass's motion along that direction, cos θ · v<sub>x</sub> + sin θ · v<sub>y</sub>: 0° hears only x, 90° only y. Both (x + y) adds the two motions. Changing it recomputes the strikes.</p>
210 </div>
da03127Two-oscillator chaos toy: model, experiments, figures, web appJeremy Magland 212 <div class="field">
213 <div class="label">Presets</div>
214 <div class="presets">
215 <button class="chip" type="button" data-preset="gentle">Gentle</button>
216 <button class="chip" type="button" data-preset="edge">Edge of chaos</button>
217 <button class="chip" type="button" data-preset="strong">Strong</button>
218 <button class="chip" type="button" data-preset="equal">Equal pitches</button>
219 </div>
220 </div>
222 <div class="field">
223 <div class="row"><label for="E0">Strike energy E</label><span class="val" id="E0v"></span></div>
224 <input id="E0" type="range" min="0" max="1000" step="1">
225 <p class="hint">The only parameter of the dynamics (β = 1). Chaos sets in around E ≈ 2 for ω = 1.618.</p>
226 </div>
227 <div class="field">
228 <div class="row"><label for="w2">Frequency ratio ω</label><span class="val" id="w2v"></span></div>
229 <input id="w2" type="range" min="1" max="2.5" step="0.001">
230 </div>
231 <div class="field">
232 <div class="row"><label for="ang">Strike direction</label><span class="val" id="angv"></span></div>
233 <input id="ang" type="range" min="5" max="85" step="0.5">
234 <p class="hint">How the strike's momentum is split between x and y.</p>
235 </div>
236 <div class="field">
237 <div class="row"><label for="dl">Difference between A and B</label><span class="val" id="dlv"></span></div>
238 <input id="dl" type="range" min="-14" max="-2" step="0.5">
239 </div>
240 <div class="field">
241 <div class="row"><label for="t60">Ring time (−60 dB)</label><span class="val" id="t60v"></span></div>
242 <input id="t60" type="range" min="0.5" max="10" step="0.1">
243 </div>
244 <div class="field">
245 <div class="row"><label for="f1">Pitch of x</label><span class="val" id="f1v"></span></div>
246 <input id="f1" type="range" min="80" max="880" step="1">
247 </div>
248 <div class="field">
249 <div class="row"><label for="dur">Length</label><span class="val" id="durv"></span></div>
250 <input id="dur" type="range" min="1" max="8" step="0.5">
251 </div>
253 <dl class="stats" aria-live="polite">
254 <dt>Chaotic phase ends</dt><dd id="sChaos">–</dd>
255 <dt>Peak separation rate</dt><dd id="sLam">–</dd>
256 <dt>Final share in x, A</dt><dd id="sA">–</dd>
257 <dt>Final share in x, B</dt><dd id="sB">–</dd>
258 <dt>Spread over 12 strikes</dt><dd id="sSpread">–</dd>
259 </dl>
260 </aside>
262 <main>
263 <figure>
264 <figcaption><b>Physical setup</b><span>One mass, free to slide in a plane, held by springs whose anchors roll along the walls, so each spring pushes only along its own axis.</span></figcaption>
265 <div class="setup">
266 <div class="plot setupplot"><canvas id="cSetup"></canvas></div>
267 <div class="setuptext">
268 <p>The mass (m = 1) sits at rest in the middle until the strike: a sharp blow that gives it energy E in the chosen direction. It then oscillates along x at frequency 1 and along y at frequency ω.</p>
269 <p>What couples the two motions is the stiffness. The springs along x get stiffer the farther the mass is from the x axis, and the springs along y get stiffer the farther it is from the y axis:</p>
270 <p class="k">k<sub>x</sub> = 1 + 2y² k<sub>y</sub> = ω² + 2x²</p>
271 <p>That is the x²y² term in the energy. A plate behaves the same way: bending it one way stretches its midplane, which stiffens it for every other way of bending. A light friction on the mass sets the ring time.</p>
272 <button class="btn" id="slow" type="button" aria-pressed="false">Slow motion</button>
273 </div>
274 </div>
275 </figure>
277 <div class="pair">
278 <figure>
279 <figcaption><b>Motion in the bowl</b><span>The same mass seen as a ball rolling in a bowl, V(x, y) = ½x² + ½ω²y² + x²y². Bands are heights of 10%, 25%, 50% and 100% of the strike energy; the ball cannot climb past the outer one. The line is the path of the selected strike (A unless you choose another below).</span></figcaption>
280 <div class="plot sq"><canvas id="cPath"></canvas><canvas id="cPathO"></canvas></div>
281 </figure>
282 <figure>
283 <figcaption><b>Phase space at the strike energy</b><span>Poincaré section of the undamped system (y = 0, p<sub>y</sub> > 0). Closed curves are regular motion, scattered dots chaos. The ring marks where the strike starts.</span></figcaption>
284 <div class="plot sq"><canvas id="cSec"></canvas></div>
285 </figure>
286 </div>
288 <figure>
289 <figcaption><b>Who gets the energy</b><span>Share of the energy in the x oscillator for 12 strikes, each differing from the next by the A/B difference. The shaded span is the chaotic phase of strike A.</span></figcaption>
290 <div class="plot wide"><canvas id="cShare"></canvas><canvas id="cShareO"></canvas></div>
291 <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>10 more strikes</span></div>
292 </figure>
294 <figure>
295 <figcaption><b>How fast nearby strikes separate</b><span>Local Lyapunov exponent of strike A (per second, 20-period windows). Above zero, a difference between two strikes grows exponentially. The dashed line is the energy, in dB.</span></figcaption>
296 <div class="plot short"><canvas id="cLam"></canvas><canvas id="cLamO"></canvas></div>
297 </figure>
299 <figure>
f7cb0afWeb app: listener direction or sum, layout fix; link the gong-wire and fput-plate toysJeremy Magland 300 <figcaption><b>What you hear</b><span>Spectrogram of the selected strike, as heard by the listener. Broadband smear means chaos; clean lines mean regular ringing. Click a strike, or use the ← and → keys, to flip between the 12; the color scale is shared, so differences in level are real. The selected strike is also drawn bold in the energy-share plot and as the path in the bowl.</span></figcaption>
da03127Two-oscillator chaos toy: model, experiments, figures, web appJeremy Magland 301 <div class="strikes" role="group" aria-label="Choose a strike" id="strikeSel"></div>
302 <div class="plot wide"><canvas id="cSpec"></canvas><canvas id="cSpecO"></canvas></div>
303 </figure>
305 <footer>
306 <p>Integrated in your browser with a fourth-order symplectic scheme (Yoshida), damping applied to the momenta. Time is scaled so that ω<sub>x</sub> = 1 sounds at the chosen pitch. The model is the toy behind the struck-plate study in cymbal-elastodynamics: the plate's modes are oscillators coupled through the same kind of quartic stretching energy.</p>
307 </footer>
308 </main>
309 </div>
310</div>
312<script id="worker-src" type="text/plain">
313const C = Math.cbrt(2), Y1 = 1 / (2 - C), Y0 = -C / (2 - C);
314const COEF = [Y1, Y0, Y1];
316function hmaxFor(E) { return Math.min(0.02, 0.1 / Math.sqrt(1 + 4 * E)); }
318// one strike: state s = [x, y, px, py]; optional tangent d for the Lyapunov exponent
319function strike(p, angle, withTangent, audioRate) {
320 const w2sq = p.w2 * p.w2;
321 const P = Math.sqrt(2 * p.E0);
322 let x = 0, y = 0, px = P * Math.cos(angle), py = P * Math.sin(angle);
323 let dx = 0.6, dy = 0.3, dpx = -0.5, dpy = 0.55;
324 { const n = Math.hypot(dx, dy, dpx, dpy); dx /= n; dy /= n; dpx /= n; dpy /= n; }
325 const nOut = p.nOut, dtOut = p.dtOut;
326 const nsub = Math.max(1, Math.ceil(dtOut / hmaxFor(p.E0)));
327 const h = dtOut / nsub;
328 const damp = Math.exp(-p.gamma * h);
329 const K = p.decim;
330 const nD = Math.floor(nOut / K);
331 const sig = audioRate ? new Float32Array(nOut) : null;
332 const xs = audioRate ? new Float32Array(nOut) : null;
333 const ys = audioRate ? new Float32Array(nOut) : null;
334 const share = new Float32Array(nD);
335 const energy = new Float32Array(nD);
336 const logsum = withTangent ? new Float64Array(nD) : null;
337 let L = 0;
338 for (let i = 0; i < nOut; i++) {
339 for (let s = 0; s < nsub; s++) {
340 for (let c = 0; c < 3; c++) {
341 const hh = COEF[c] * h;
342 let fx = -x - 2 * x * y * y, fy = -w2sq * y - 2 * x * x * y;
343 px += 0.5 * hh * fx; py += 0.5 * hh * fy;
344 if (withTangent) {
345 const fxx = -1 - 2 * y * y, fyy = -w2sq - 2 * x * x, fxy = -4 * x * y;
346 dpx += 0.5 * hh * (fxx * dx + fxy * dy); dpy += 0.5 * hh * (fxy * dx + fyy * dy);
347 dx += hh * dpx; dy += hh * dpy;
348 }
349 x += hh * px; y += hh * py;
350 fx = -x - 2 * x * y * y; fy = -w2sq * y - 2 * x * x * y;
351 px += 0.5 * hh * fx; py += 0.5 * hh * fy;
352 if (withTangent) {
353 const fxx = -1 - 2 * y * y, fyy = -w2sq - 2 * x * x, fxy = -4 * x * y;
354 dpx += 0.5 * hh * (fxx * dx + fxy * dy); dpy += 0.5 * hh * (fxy * dx + fyy * dy);
355 }
356 }
357 px *= damp; py *= damp;
358 if (withTangent) { dpx *= damp; dpy *= damp; }
359 }
360 if (withTangent) {
361 const n = Math.hypot(dx, dy, dpx, dpy);
362 L += Math.log(n); dx /= n; dy /= n; dpx /= n; dpy /= n;
363 }
f7cb0afWeb app: listener direction or sum, layout fix; link the gong-wire and fput-plate toysJeremy Magland 364 if (audioRate) { sig[i] = p.lx * px + p.ly * py; xs[i] = x; ys[i] = y; }
da03127Two-oscillator chaos toy: model, experiments, figures, web appJeremy Magland 365 if ((i + 1) % K === 0) {
366 const j = (i + 1) / K - 1;
367 if (j < nD) {
368 const ex = 0.5 * (px * px + x * x), ey = 0.5 * (py * py + w2sq * y * y);
369 share[j] = ex / (ex + ey + 1e-300);
370 energy[j] = ex + ey + x * x * y * y;
371 if (withTangent) logsum[j] = L;
372 }
373 }
374 }
375 return { sig, xs, ys, share, energy, logsum };
376}
378function section(p) {
379 // undamped orbits at energy E0 started on the section, plus the strike orbit
380 const w2sq = p.w2 * p.w2, E = p.E0;
381 const h = hmaxFor(E), T = 1200;
382 const n = Math.round(T / h);
383 const ics = [];
384 const xm = Math.sqrt(2 * E), pm = Math.sqrt(2 * E);
385 const g = 7;
386 for (let a = 1; a <= g; a++) for (let b = 1; b <= g; b++) {
387 const X = -xm + 2 * xm * a / (g + 1), Pp = -pm + 2 * pm * b / (g + 1);
388 const rest = E - 0.5 * Pp * Pp - 0.5 * X * X;
389 if (rest > 1e-6 * E) ics.push([X, 0, Pp, Math.sqrt(2 * rest)]);
390 }
391 const P = Math.sqrt(2 * E);
392 ics.push([0, 0, P * Math.cos(p.angle), P * Math.sin(p.angle)]);
393 const out = [];
394 for (const ic of ics) {
395 let [x, y, px, py] = ic;
396 let dx = 0.6, dy = 0.3, dpx = -0.5, dpy = 0.55, L = 0;
397 { const nn = Math.hypot(dx, dy, dpx, dpy); dx /= nn; dy /= nn; dpx /= nn; dpy /= nn; }
398 const pts = [];
399 for (let i = 0; i < n; i++) {
400 const yOld = y, xOld = x, pxOld = px;
401 for (let c = 0; c < 3; c++) {
402 const hh = COEF[c] * h;
403 let fx = -x - 2 * x * y * y, fy = -w2sq * y - 2 * x * x * y;
404 let fxx = -1 - 2 * y * y, fyy = -w2sq - 2 * x * x, fxy = -4 * x * y;
405 px += 0.5 * hh * fx; py += 0.5 * hh * fy;
406 dpx += 0.5 * hh * (fxx * dx + fxy * dy); dpy += 0.5 * hh * (fxy * dx + fyy * dy);
407 dx += hh * dpx; dy += hh * dpy;
408 x += hh * px; y += hh * py;
409 fx = -x - 2 * x * y * y; fy = -w2sq * y - 2 * x * x * y;
410 fxx = -1 - 2 * y * y; fyy = -w2sq - 2 * x * x; fxy = -4 * x * y;
411 px += 0.5 * hh * fx; py += 0.5 * hh * fy;
412 dpx += 0.5 * hh * (fxx * dx + fxy * dy); dpy += 0.5 * hh * (fxy * dx + fyy * dy);
413 }
414 if (i % 10 === 9) { const nn = Math.hypot(dx, dy, dpx, dpy); L += Math.log(nn); dx /= nn; dy /= nn; dpx /= nn; dpy /= nn; }
415 if (yOld < 0 && y >= 0 && py > 0) {
416 const f = -yOld / (y - yOld);
417 pts.push((1 - f) * xOld + f * x, (1 - f) * pxOld + f * px);
418 }
419 }
420 out.push({ pts: new Float32Array(pts), lam: L / T });
421 }
422 return out;
423}
425let gen = 0;
426onmessage = (ev) => {
427 const m = ev.data;
428 gen = m.gen;
429 const p = m.p;
430 const a = strike(p, p.angle, true, true);
431 const b = strike(p, p.angle + p.delta, true, true);
432 postMessage({ kind: "strikes", gen: m.gen, a, b },
433 [a.sig.buffer, a.xs.buffer, a.ys.buffer, a.share.buffer, a.energy.buffer, a.logsum.buffer,
434 b.sig.buffer, b.xs.buffer, b.ys.buffer, b.share.buffer, b.energy.buffer, b.logsum.buffer]);
435 for (let k = 2; k < 12; k++) {
436 const r = strike(p, p.angle + k * p.delta, false, true);
437 postMessage({ kind: "member", gen: m.gen, k, share: r.share, sig: r.sig, xs: r.xs, ys: r.ys },
438 [r.share.buffer, r.sig.buffer, r.xs.buffer, r.ys.buffer]);
439 }
440 postMessage({ kind: "section", gen: m.gen, sec: section(p) });
441};
442</script>
444<script>
445(() => {
446 const SR = 44100;
447 const $ = (id) => document.getElementById(id);
448 const css = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
450 const PRESETS = {
451 gentle: { E0: 0.5, w2: 1.618, ang: 40, dl: -10, t60: 4, f1: 220, dur: 4 },
452 edge: { E0: 3, w2: 1.618, ang: 40, dl: -10, t60: 4, f1: 220, dur: 4 },
453 strong: { E0: 20, w2: 1.618, ang: 40, dl: -10, t60: 4, f1: 220, dur: 4 },
454 equal: { E0: 4, w2: 1.0, ang: 40, dl: -10, t60: 4, f1: 220, dur: 4 },
455 };
457 // slider <-> value maps
458 const eFromSlider = (v) => Math.pow(10, -1 + 3 * v / 1000);
459 const sliderFromE = (E) => Math.round((Math.log10(E) + 1) / 3 * 1000);
460 const fmt = (v, d = 2) => Number(v).toFixed(d);
462 function readParams() {
463 const E0 = eFromSlider(+$("E0").value);
464 const w2 = +$("w2").value, angDeg = +$("ang").value, dl = +$("dl").value;
465 const t60 = +$("t60").value, f1 = +$("f1").value, dur = +$("dur").value;
466 $("E0v").textContent = E0 < 10 ? fmt(E0, 2) : fmt(E0, 1);
467 $("w2v").textContent = fmt(w2, 3) + (Math.abs(w2 - 1.618) < 0.002 ? " (golden)" : "");
468 $("angv").textContent = fmt(angDeg, 1) + "°";
469 $("dlv").textContent = "1e" + dl + " rad";
470 $("t60v").textContent = fmt(t60, 1) + " s";
471 $("f1v").textContent = Math.round(f1) + " Hz (y: " + Math.round(f1 * w2) + " Hz)";
472 $("durv").textContent = fmt(dur, 1) + " s";
f7cb0afWeb app: listener direction or sum, layout fix; link the gong-wire and fput-plate toysJeremy Magland 473 const both = $("lisSum").getAttribute("aria-pressed") === "true", th = +$("lis").value * Math.PI / 180;
474 $("lisv").textContent = both ? "x + y" : "θ = " + $("lis").value + "°";
475 $("lis").disabled = both;
da03127Two-oscillator chaos toy: model, experiments, figures, web appJeremy Magland 476 const tau = 1 / (2 * Math.PI * f1); // seconds per time unit
477 const nOut = Math.round(dur * SR);
478 return {
479 E0, w2, angle: angDeg * Math.PI / 180, delta: Math.pow(10, dl), tau,
480 // momenta damped by exp(-gamma dt): energy decays at rate gamma, amplitude at gamma / 2
481 gamma: 2 * Math.log(1000) / (t60 / tau), dtOut: 1 / SR / tau, nOut, decim: Math.round(SR / 400), dur,
f7cb0afWeb app: listener direction or sum, layout fix; link the gong-wire and fput-plate toysJeremy Magland 482 lx: both ? 1 : Math.cos(th), ly: both ? 1 : Math.sin(th), listenBoth: both, listenAngle: th,
484 }
486 function setControls(p) {
487 $("E0").value = sliderFromE(p.E0); $("w2").value = p.w2; $("ang").value = p.ang; $("dl").value = p.dl;
488 $("t60").value = p.t60; $("f1").value = p.f1; $("dur").value = p.dur;
489 }
491 // ---------------- worker ----------------
492 const workerURL = URL.createObjectURL(new Blob([$("worker-src").textContent], { type: "text/javascript" }));
493 let worker = null, gen = 0, params = null;
494 let A = null, B = null, members = [], sec = null;
496 function run() {
497 stopAudio();
498 if (smOn) { smOn = false; cancelAnimationFrame(smRaf); $("slow").setAttribute("aria-pressed", "false"); $("slow").textContent = "Slow motion"; }
499 params = readParams();
500 gen++;
501 if (worker) worker.terminate();
502 worker = new Worker(workerURL);
503 worker.onmessage = onResult;
504 A = B = null; members = []; sec = null;
505 $("status").textContent = "Computing…";
506 worker.postMessage({ gen, p: params });
507 drawAll();
508 }
510 function onResult(ev) {
511 const m = ev.data;
512 if (m.gen !== gen) return;
513 if (m.kind === "strikes") {
514 A = m.a; B = m.b;
515 members = [];
516 prepareAudio();
517 updateStats();
518 $("status").textContent = "Ready. Strikes differ by 1e" + $("dl").value + " rad in direction.";
519 } else if (m.kind === "member") {
520 members[m.k] = { share: m.share, sig: m.sig, xs: m.xs, ys: m.ys, audio: scaleAudio(m.sig) };
521 updateStats();
522 } else if (m.kind === "section") {
523 sec = m.sec;
524 }
525 renderSelector();
526 drawAll();
527 }
529 // ---------------- strike selector ----------------
530 const NS = 12;
531 let sel = 0;
532 const strikeAt = (k) => (k === 0 ? A : k === 1 ? B : members[k] || null);
533 const strikeColor = (k) => (k === 0 ? css("--x") : k === 1 ? css("--y") : css("--ink"));
534 const strikeName = (k) => (k === 0 ? "A" : k === 1 ? "B" : String(k + 1));
535 function renderSelector() {
536 const box = $("strikeSel");
537 if (!box.childElementCount) {
538 for (let k = 0; k < NS; k++) {
539 const b = document.createElement("button");
540 b.type = "button"; b.className = "sk"; b.id = "sk" + k;
541 b.innerHTML = '<span class="sw"></span>' + strikeName(k);
542 b.addEventListener("click", () => selectStrike(k));
543 box.appendChild(b);
544 }
545 const pb = document.createElement("button");
546 pb.type = "button"; pb.className = "btn"; pb.id = "playSel"; pb.textContent = "Play this strike";
547 pb.addEventListener("click", () => play(sel, null));
548 box.appendChild(pb);
549 }
550 for (let k = 0; k < NS; k++) {
551 const b = $("sk" + k);
552 b.disabled = !strikeAt(k);
553 b.setAttribute("aria-pressed", String(k === sel));
554 b.querySelector(".sw").style.background = strikeColor(k);
555 b.title = strikeAt(k) ? "Strike " + strikeName(k) + ": direction + " + k + " × 1e" + $("dl").value + " rad" : "computing…";
556 }
557 $("playSel").disabled = !strikeAt(sel);
558 }
559 function selectStrike(k) {
560 if (!strikeAt(k)) return;
561 sel = k;
562 renderSelector();
563 drawPath(); drawShare(); drawSpec();
564 }
565 document.addEventListener("keydown", (e) => {
566 if (e.target && (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")) return;
567 if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
568 const dir = e.key === "ArrowRight" ? 1 : -1;
569 for (let i = 1; i <= NS; i++) {
570 const k = (sel + dir * i + NS) % NS;
571 if (strikeAt(k)) { selectStrike(k); e.preventDefault(); return; }
572 }
573 });
575 // ---------------- derived quantities ----------------
576 function localLyap(logsum) {
577 // per second, over windows of 20 periods of x (20 * 2 pi time units)
578 const dtD = params.decim * params.dtOut; // time units per decimated sample
579 const w = Math.max(1, Math.round(20 * 2 * Math.PI / dtD));
580 const out = new Float32Array(logsum.length);
581 for (let i = 0; i < logsum.length; i++) {
582 const j = Math.max(0, i - w);
583 out[i] = i > j ? (logsum[i] - logsum[j]) / ((i - j) * dtD) / params.tau : NaN;
584 }
585 return out;
586 }
588 function chaosEnd(lam) {
589 // last time the local exponent is clearly positive (0.02 per time unit)
590 const thr = 0.02 / params.tau;
591 let last = -1;
592 for (let i = 0; i < lam.length; i++) if (lam[i] > thr) last = i;
593 return last < 0 ? null : (last + 1) * params.decim / SR;
594 }
596 function updateStats() {
597 if (!A) return;
598 const lam = localLyap(A.logsum);
599 A.lam = lam;
600 const end = chaosEnd(lam);
601 $("sChaos").textContent = end === null ? "never chaotic" : fmt(end, 2) + " s";
602 let peak = 0;
603 for (const v of lam) if (v > peak) peak = v;
604 $("sLam").textContent = peak > 0.02 / params.tau ? fmt(peak, 0) + " /s" : "≈ 0";
605 const fin = (s) => { let t = 0, n = 0; for (let i = Math.floor(s.length * 0.85); i < s.length; i++) { t += s[i]; n++; } return t / n; };
606 const fa = fin(A.share), fb = fin(B.share);
607 $("sA").textContent = fmt(fa, 3);
608 $("sB").textContent = fmt(fb, 3);
609 const all = [fa, fb];
610 for (let k = 2; k < 12; k++) if (members[k]) all.push(fin(members[k].share));
611 const mean = all.reduce((s, v) => s + v, 0) / all.length;
612 const sd = Math.sqrt(all.reduce((s, v) => s + (v - mean) ** 2, 0) / all.length);
613 let lo = Math.min(...all), hi = Math.max(...all);
614 $("sSpread").textContent = all.length < 12 ? "computing…" : fmt(lo, 2) + " to " + fmt(hi, 2);
615 }
617 // ---------------- canvas helpers ----------------
618 function setup(canvas) {
619 const r = canvas.getBoundingClientRect();
620 const dpr = window.devicePixelRatio || 1;
621 const w = Math.max(1, Math.round(r.width * dpr)), h = Math.max(1, Math.round(r.height * dpr));
622 if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
623 const ctx = canvas.getContext("2d");
624 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
625 ctx.clearRect(0, 0, r.width, r.height);
626 return { ctx, W: r.width, H: r.height };
627 }
629 const PAD = { l: 44, r: 12, t: 10, b: 26 };
631 function axesTime(ctx, W, H, ymin, ymax, yticks, ylabelFmt, dur) {
632 const x0 = PAD.l, x1 = W - PAD.r, y0 = PAD.t, y1 = H - PAD.b;
633 ctx.strokeStyle = css("--grid"); ctx.lineWidth = 1;
634 ctx.fillStyle = css("--muted"); ctx.font = "11px " + css("--mono");
635 ctx.textAlign = "right"; ctx.textBaseline = "middle";
636 for (const v of yticks) {
637 const y = y1 - (v - ymin) / (ymax - ymin) * (y1 - y0);
638 ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x1, y); ctx.stroke();
639 ctx.fillText(ylabelFmt(v), x0 - 6, y);
640 }
641 ctx.textAlign = "center"; ctx.textBaseline = "top";
642 const step = dur <= 2 ? 0.25 : dur <= 5 ? 0.5 : 1;
643 for (let t = 0; t <= dur + 1e-9; t += step) {
644 const x = x0 + t / dur * (x1 - x0);
645 ctx.beginPath(); ctx.moveTo(x, y0); ctx.lineTo(x, y1); ctx.stroke();
646 ctx.fillText(fmt(t, step < 1 ? (step < 0.5 ? 2 : 1) : 0) + (t === 0 ? " s" : ""), x, y1 + 6);
647 }
648 return { x0, x1, y0, y1, X: (t) => x0 + t / dur * (x1 - x0), Y: (v) => y1 - (v - ymin) / (ymax - ymin) * (y1 - y0) };
649 }
651 function chaosSpan(ctx, ax) {
652 if (!A || !A.lam) return;
653 const end = chaosEnd(A.lam);
654 if (end === null) return;
655 ctx.fillStyle = css("--chaos-wash");
656 ctx.fillRect(ax.x0, ax.y0, ax.X(end) - ax.x0, ax.y1 - ax.y0);
657 }
659 function seriesTime(ctx, ax, arr, color, width, dash) {
660 if (!arr) return;
661 const n = arr.length, dt = params.decim / SR;
662 ctx.strokeStyle = color; ctx.lineWidth = width; ctx.setLineDash(dash || []);
663 ctx.beginPath();
664 let started = false;
665 for (let i = 0; i < n; i++) {
666 const v = arr[i];
667 if (!Number.isFinite(v)) { started = false; continue; }
668 const x = ax.X((i + 1) * dt), y = ax.Y(v);
669 if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);
670 }
671 ctx.stroke(); ctx.setLineDash([]);
672 }
674 // ---------------- plots ----------------
675 function drawShare() {
676 const { ctx, W, H } = setup($("cShare"));
677 const ax = axesTime(ctx, W, H, 0, 1, [0, 0.25, 0.5, 0.75, 1], (v) => fmt(v, 2), params.dur);
678 chaosSpan(ctx, ax);
679 for (let k = 2; k < 12; k++) if (members[k]) seriesTime(ctx, ax, members[k].share, css("--faint"), 1);
680 if (B) seriesTime(ctx, ax, B.share, css("--y"), 1.4);
681 if (A) seriesTime(ctx, ax, A.share, css("--x"), 1.6);
682 const S = strikeAt(sel);
683 if (S) seriesTime(ctx, ax, S.share, strikeColor(sel), 3);
684 }
686 function drawLam() {
687 const { ctx, W, H } = setup($("cLam"));
688 if (!A || !A.lam) { axesTime(ctx, W, H, 0, 1, [0, 1], () => "", params.dur); return; }
689 let mx = 0;
690 for (const v of A.lam) if (v > mx) mx = v;
691 mx = Math.max(mx * 1.1, 0.05 / params.tau);
692 const nice = niceStep(mx / 3);
693 const ticks = [];
694 for (let v = 0; v <= mx; v += nice) ticks.push(v);
695 const ax = axesTime(ctx, W, H, -0.15 * mx, mx, ticks, (v) => fmt(v, 0), params.dur);
696 chaosSpan(ctx, ax);
697 ctx.strokeStyle = css("--rule"); ctx.lineWidth = 1;
698 ctx.beginPath(); ctx.moveTo(ax.x0, ax.Y(0)); ctx.lineTo(ax.x1, ax.Y(0)); ctx.stroke();
699 // energy in dB mapped onto the same vertical span (0 dB at top, -80 dB at bottom)
700 const e = A.energy, e0 = e[0] || 1, eDb = new Float32Array(e.length);
701 for (let i = 0; i < e.length; i++) {
702 const db = 10 * Math.log10(e[i] / e0);
703 eDb[i] = -0.15 * mx + (Math.max(db, -80) + 80) / 80 * 1.15 * mx;
704 }
705 seriesTime(ctx, ax, eDb, css("--muted"), 1, [4, 4]);
706 seriesTime(ctx, ax, A.lam, css("--x"), 1.6);
707 }
709 function niceStep(v) {
710 const p = Math.pow(10, Math.floor(Math.log10(v)));
711 const m = v / p;
712 return (m < 1.5 ? 1 : m < 3.5 ? 2 : m < 7.5 ? 5 : 10) * p;
713 }
715 let pathScale = 1;
716 function drawPath() {
717 const { ctx, W, H } = setup($("cPath"));
718 if (!A) return;
719 let m = 0;
720 for (let i = 0; i < A.xs.length; i++) m = Math.max(m, Math.abs(A.xs[i]), Math.abs(A.ys[i]));
721 pathScale = (Math.min(W, H) / 2 - 14) / (m || 1);
722 drawBowl(ctx, W, H, pathScale);
723 ctx.strokeStyle = css("--grid"); ctx.lineWidth = 1;
724 ctx.beginPath(); ctx.moveTo(W / 2, 8); ctx.lineTo(W / 2, H - 8); ctx.moveTo(8, H / 2); ctx.lineTo(W - 8, H / 2); ctx.stroke();
725 ctx.fillStyle = css("--muted"); ctx.font = "11px " + css("--mono");
726 ctx.textAlign = "right"; ctx.textBaseline = "top"; ctx.fillText("x", W - 10, H / 2 + 4);
727 ctx.textAlign = "left"; ctx.fillText("y", W / 2 + 6, 8);
728 const P = strikeAt(sel) || A;
729 ctx.strokeStyle = strikeColor(strikeAt(sel) ? sel : 0); ctx.globalAlpha = 0.22; ctx.lineWidth = 0.7;
730 ctx.beginPath();
731 const n = P.xs.length, stride = n > 200000 ? 2 : 1;
732 ctx.moveTo(W / 2 + P.xs[0] * pathScale, H / 2 - P.ys[0] * pathScale);
733 for (let i = stride; i < n; i += stride) ctx.lineTo(W / 2 + P.xs[i] * pathScale, H / 2 - P.ys[i] * pathScale);
734 ctx.stroke(); ctx.globalAlpha = 1;
735 ctx.fillStyle = strikeColor(strikeAt(sel) ? sel : 0); ctx.font = "600 12px " + css("--sans"); ctx.textAlign = "left"; ctx.textBaseline = "bottom";
736 ctx.fillText("strike " + strikeName(strikeAt(sel) ? sel : 0), 10, H - 8);
737 }
739 // potential V(x, y) as bands at fractions of the strike energy
740 function drawBowl(ctx, W, H, scale) {
741 const dpr = window.devicePixelRatio || 1;
742 const nw = Math.max(1, Math.round(W)), nh = Math.max(1, Math.round(H));
743 const off = document.createElement("canvas");
744 off.width = nw; off.height = nh;
745 const octx = off.getContext("2d");
746 const img = octx.createImageData(nw, nh);
747 const E = params.E0, w2sq = params.w2 * params.w2;
748 const levels = [0.1, 0.25, 0.5, 1.0];
749 const panel = hexToRgb(css("--panel")), ink = hexToRgb(css("--ink")), xcol = hexToRgb(css("--x"));
750 for (let j = 0; j < nh; j++) {
751 const y = (H / 2 - j) / scale;
752 for (let i = 0; i < nw; i++) {
753 const x = (i - W / 2) / scale;
754 const v = (0.5 * x * x + 0.5 * w2sq * y * y + x * x * y * y) / E;
755 let band = 0;
756 while (band < levels.length && v > levels[band]) band++;
757 const c = band >= levels.length ? mix(panel, ink, 0.10) : mix(panel, xcol, 0.04 + 0.035 * (levels.length - 1 - band));
758 const o = (j * nw + i) * 4;
759 img.data[o] = c[0]; img.data[o + 1] = c[1]; img.data[o + 2] = c[2]; img.data[o + 3] = 255;
760 }
761 }
762 octx.putImageData(img, 0, 0);
763 ctx.drawImage(off, 0, 0, W, H);
764 }
766 // ---------------- physical setup ----------------
767 let smIdx = 0, smOn = false, smRaf = 0, smLast = 0;
768 function zigzag(ctx, x0, y0, x1, y1, coils, amp) {
769 const dx = x1 - x0, dy = y1 - y0, L = Math.hypot(dx, dy) || 1;
770 const ux = dx / L, uy = dy / L, nx = -uy, ny = ux;
771 const lead = Math.min(10, L * 0.15);
772 ctx.beginPath();
773 ctx.moveTo(x0, y0);
774 ctx.lineTo(x0 + ux * lead, y0 + uy * lead);
775 const n = coils * 2, seg = (L - 2 * lead) / n;
776 for (let k = 1; k < n; k++) {
777 const s = lead + k * seg, side = k % 2 ? 1 : -1;
778 ctx.lineTo(x0 + ux * s + nx * amp * side, y0 + uy * s + ny * amp * side);
779 }
780 ctx.lineTo(x1 - ux * lead, y1 - uy * lead);
781 ctx.lineTo(x1, y1);
782 ctx.stroke();
783 }
784 function drawSetup() {
785 const { ctx, W, H } = setup($("cSetup"));
786 const fx0 = 22, fx1 = W - 22, fy0 = 18, fy1 = H - 18;
787 const cx = (fx0 + fx1) / 2, cy = (fy0 + fy1) / 2;
788 const ink = css("--ink"), muted = css("--muted");
789 // walls with hatching
790 ctx.strokeStyle = muted; ctx.lineWidth = 1.5;
791 ctx.strokeRect(fx0, fy0, fx1 - fx0, fy1 - fy0);
792 ctx.lineWidth = 1; ctx.strokeStyle = css("--rule");
793 for (let t = fx0; t < fx1; t += 9) { ctx.beginPath(); ctx.moveTo(t, fy0); ctx.lineTo(t - 6, fy0 - 6); ctx.moveTo(t, fy1); ctx.lineTo(t + 6, fy1 + 6); ctx.stroke(); }
794 for (let t = fy0; t < fy1; t += 9) { ctx.beginPath(); ctx.moveTo(fx0, t); ctx.lineTo(fx0 - 6, t + 6); ctx.moveTo(fx1, t); ctx.lineTo(fx1 + 6, t - 6); ctx.stroke(); }
795 // position
796 let x = 0, y = 0, i = 0;
797 if (A && smOn) { i = Math.min(A.xs.length - 1, Math.floor(smIdx)); x = A.xs[i]; y = A.ys[i]; }
798 let m = 0;
799 if (A) for (let k = 0; k < A.xs.length; k += 7) m = Math.max(m, Math.abs(A.xs[k]), Math.abs(A.ys[k]));
800 const sc = 0.62 * Math.min(cx - fx0, cy - fy0) / (m || 1);
801 const px = cx + x * sc, py = cy - y * sc, r = 13;
802 const w2sq = params ? params.w2 * params.w2 : 1;
803 const kx = 1 + 2 * y * y, ky = w2sq + 2 * x * x;
804 const lw = (k) => Math.min(5.5, 1 + 1.3 * Math.log(1 + k));
805 // trail
806 if (A && smOn && i > 1) {
807 const span = Math.round(2 * Math.PI * 1.5 / params.dtOut);
808 ctx.strokeStyle = css("--x"); ctx.globalAlpha = 0.25; ctx.lineWidth = 1;
809 ctx.beginPath();
810 const i0 = Math.max(0, i - span);
811 ctx.moveTo(cx + A.xs[i0] * sc, cy - A.ys[i0] * sc);
812 for (let k = i0 + 1; k <= i; k++) ctx.lineTo(cx + A.xs[k] * sc, cy - A.ys[k] * sc);
813 ctx.stroke(); ctx.globalAlpha = 1;
814 }
815 // springs: x springs to rollers on the side walls, y springs to rollers on the top and bottom walls
816 ctx.lineJoin = "round";
817 ctx.strokeStyle = css("--x"); ctx.lineWidth = lw(kx);
818 zigzag(ctx, fx0 + 5, py, px - r, py, 7, 6);
819 zigzag(ctx, px + r, py, fx1 - 5, py, 7, 6);
820 ctx.strokeStyle = css("--y"); ctx.lineWidth = lw(ky);
821 zigzag(ctx, px, fy0 + 5, px, py - r, 5, 6);
822 zigzag(ctx, px, py + r, px, fy1 - 5, 5, 6);
823 // rollers
824 ctx.fillStyle = css("--panel"); ctx.strokeStyle = muted; ctx.lineWidth = 1.5;
825 for (const [rx, ry] of [[fx0 + 5, py], [fx1 - 5, py], [px, fy0 + 5], [px, fy1 - 5]]) {
826 ctx.beginPath(); ctx.arc(rx, ry, 4.5, 0, 2 * Math.PI); ctx.fill(); ctx.stroke();
827 }
828 // mass
829 ctx.fillStyle = ink; ctx.beginPath(); ctx.arc(px, py, r, 0, 2 * Math.PI); ctx.fill();
830 ctx.fillStyle = css("--panel"); ctx.font = "600 12px " + css("--sans"); ctx.textAlign = "center"; ctx.textBaseline = "middle";
831 ctx.fillText("m", px, py + 0.5);
832 // strike arrow at rest
833 if (!smOn && params) {
834 const a = params.angle, L = 0.42 * Math.min(cx - fx0, cy - fy0);
835 const ex = cx + Math.cos(a) * L, ey = cy - Math.sin(a) * L;
836 ctx.strokeStyle = css("--chaos"); ctx.fillStyle = css("--chaos"); ctx.lineWidth = 3;
837 ctx.beginPath(); ctx.moveTo(cx + Math.cos(a) * (r + 3), cy - Math.sin(a) * (r + 3)); ctx.lineTo(ex, ey); ctx.stroke();
838 const ha = 0.45, hl = 11;
839 ctx.beginPath(); ctx.moveTo(ex + Math.cos(a) * 3, ey - Math.sin(a) * 3);
840 ctx.lineTo(ex - hl * Math.cos(a - ha), ey + hl * Math.sin(a - ha));
841 ctx.lineTo(ex - hl * Math.cos(a + ha), ey + hl * Math.sin(a + ha)); ctx.closePath(); ctx.fill();
842 ctx.fillStyle = ink; ctx.font = "600 12px " + css("--sans"); ctx.textAlign = "left"; ctx.textBaseline = "bottom";
843 ctx.fillText("strike, E = " + (params.E0 < 10 ? fmt(params.E0, 2) : fmt(params.E0, 1)), ex + 8, ey - 2);
844 }
f7cb0afWeb app: listener direction or sum, layout fix; link the gong-wire and fput-plate toysJeremy Magland 845 // listener: a marker near the frame in the listening direction (two markers for x + y)
846 if (params) {
847 const dirs = params.listenBoth ? [0, Math.PI / 2] : [params.listenAngle];
848 ctx.strokeStyle = css("--y"); ctx.fillStyle = css("--y"); ctx.lineWidth = 1.5;
849 for (const th of dirs) {
850 const Rx = cx - fx0 - 22, Ry = cy - fy0 - 22;
851 const s = 1 / Math.max(Math.abs(Math.cos(th)) / Rx, Math.abs(Math.sin(th)) / Ry);
852 const lx = cx + Math.cos(th) * s, ly = cy - Math.sin(th) * s;
853 ctx.setLineDash([3, 4]); ctx.globalAlpha = 0.6;
854 ctx.beginPath(); ctx.moveTo(cx + Math.cos(th) * (r + 6), cy - Math.sin(th) * (r + 6)); ctx.lineTo(lx, ly); ctx.stroke();
855 ctx.setLineDash([]); ctx.globalAlpha = 1;
856 ctx.beginPath(); ctx.arc(lx, ly, 6, 0, 2 * Math.PI); ctx.fill();
857 for (const rr of [10, 14]) { ctx.beginPath(); ctx.arc(lx, ly, rr, Math.PI - th - 0.6, Math.PI - th + 0.6); ctx.stroke(); }
858 }
859 ctx.font = "11px " + css("--mono"); ctx.textAlign = "right"; ctx.textBaseline = "bottom";
860 ctx.fillText(params.listenBoth ? "listening to x + y" : "listener at " + Math.round(params.listenAngle * 180 / Math.PI) + "°", fx1 - 10, fy1 - 6);
861 }
da03127Two-oscillator chaos toy: model, experiments, figures, web appJeremy Magland 862 // stiffness readouts
863 ctx.font = "11px " + css("--mono"); ctx.textBaseline = "top";
864 ctx.fillStyle = css("--x"); ctx.textAlign = "left";
865 ctx.fillText("kx = " + fmt(kx, 2), fx0 + 10, fy0 + 8);
866 ctx.fillStyle = css("--y"); ctx.textAlign = "right";
867 ctx.fillText("ky = " + fmt(ky, 2), fx1 - 10, fy0 + 8);
868 if (smOn && A) {
869 ctx.fillStyle = muted; ctx.textAlign = "left"; ctx.textBaseline = "bottom";
870 const e = A.energy[Math.min(A.energy.length - 1, Math.floor(i / params.decim))] || 0;
871 ctx.fillText("t = " + fmt(i / SR * 1000, 1) + " ms of sound, energy " + fmt(e, 2), fx0 + 10, fy1 - 6);
872 }
873 }
874 function smTick(now) {
875 if (!smOn || !A) return;
876 const dtReal = smLast ? Math.min(0.1, (now - smLast) / 1000) : 0;
877 smLast = now;
878 smIdx += 3.0 * dtReal / params.dtOut; // 3 time units per second: one x period in about 2 s
879 if (smIdx >= A.xs.length - 1) { stopSlow(); return; }
880 drawSetup();
881 smRaf = requestAnimationFrame(smTick);
882 }
883 function startSlow() {
884 if (!A) return;
885 smOn = true; smIdx = 0; smLast = 0;
886 $("slow").setAttribute("aria-pressed", "true"); $("slow").textContent = "Stop slow motion";
887 smRaf = requestAnimationFrame(smTick);
888 }
889 function stopSlow() {
890 smOn = false; cancelAnimationFrame(smRaf);
891 $("slow").setAttribute("aria-pressed", "false"); $("slow").textContent = "Slow motion";
892 drawSetup();
893 }
895 function drawSection() {
896 const { ctx, W, H } = setup($("cSec"));
897 ctx.fillStyle = css("--muted"); ctx.font = "11px " + css("--mono");
898 if (!sec) { ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("mapping phase space…", W / 2, H / 2); return; }
899 const E = params.E0, xm = Math.sqrt(2 * E), pm = Math.sqrt(2 * E);
900 const sx = (W / 2 - 12) / xm, sy = (H / 2 - 12) / pm;
901 ctx.strokeStyle = css("--grid"); ctx.lineWidth = 1;
902 ctx.beginPath(); ctx.ellipse(W / 2, H / 2, xm * sx, pm * sy, 0, 0, 2 * Math.PI); ctx.stroke();
903 ctx.textAlign = "right"; ctx.textBaseline = "top"; ctx.fillText("x", W - 10, H / 2 + 4);
904 ctx.textAlign = "left"; ctx.fillText("pₓ", W / 2 + 6, 8);
905 const pal = [css("--x"), css("--y"), "#3f8f6b", "#8a6bd1", "#c07a2b", "#2f8fa3"];
906 const thr = 0.02;
907 sec.forEach((o, i) => {
908 const chaotic = o.lam > thr;
909 const isStrike = i === sec.length - 1;
910 ctx.fillStyle = chaotic ? css("--ink") : pal[i % pal.length];
911 ctx.globalAlpha = chaotic ? 0.35 : 0.9;
912 const s = chaotic ? 1 : 1.3;
913 if (isStrike) return;
914 for (let k = 0; k < o.pts.length; k += 2) ctx.fillRect(W / 2 + o.pts[k] * sx - s / 2, H / 2 - o.pts[k + 1] * sy - s / 2, s, s);
915 });
916 ctx.globalAlpha = 1;
917 // strike start: x = 0, px = sqrt(2E) cos(angle)
918 const p0 = Math.sqrt(2 * E) * Math.cos(params.angle);
919 ctx.strokeStyle = css("--chaos"); ctx.lineWidth = 2.5;
920 ctx.beginPath(); ctx.arc(W / 2, H / 2 - p0 * sy, 7, 0, 2 * Math.PI); ctx.stroke();
921 const st = sec[sec.length - 1];
922 ctx.fillStyle = css("--muted"); ctx.textAlign = "left"; ctx.textBaseline = "bottom";
923 ctx.fillText(st.lam > thr ? "strike starts in the chaotic sea" : "strike starts on a regular orbit", 10, H - 8);
924 }
926 // ---------------- spectrogram ----------------
927 let specFmax = 0;
928 function fft(re, im) {
929 const n = re.length;
930 for (let i = 1, j = 0; i < n; i++) {
931 let bit = n >> 1;
932 for (; j & bit; bit >>= 1) j ^= bit;
933 j ^= bit;
934 if (i < j) { [re[i], re[j]] = [re[j], re[i]]; [im[i], im[j]] = [im[j], im[i]]; }
935 }
936 for (let len = 2; len <= n; len <<= 1) {
937 const ang = -2 * Math.PI / len, wr = Math.cos(ang), wi = Math.sin(ang);
938 for (let i = 0; i < n; i += len) {
939 let cr = 1, ci = 0;
940 for (let k = 0; k < len / 2; k++) {
941 const a = i + k, b = a + len / 2;
942 const tr = re[b] * cr - im[b] * ci, ti = re[b] * ci + im[b] * cr;
943 re[b] = re[a] - tr; im[b] = im[a] - ti; re[a] += tr; im[a] += ti;
944 const ncr = cr * wr - ci * wi; ci = cr * wi + ci * wr; cr = ncr;
945 }
946 }
947 }
948 }
949 function spectrogramOf(S) {
950 if (S.spec) return S.spec;
951 const sig = S.sig, N = 2048, hop = 256;
952 const frames = Math.max(1, Math.floor((sig.length - N) / hop));
953 const f1 = 1 / (2 * Math.PI * params.tau);
954 specFmax = Math.min(16000, Math.max(2000, 10 * f1 * Math.max(1, params.w2)));
955 const bins = Math.floor(specFmax / (SR / N));
956 const win = new Float32Array(N);
957 for (let i = 0; i < N; i++) win[i] = 0.5 - 0.5 * Math.cos(2 * Math.PI * i / (N - 1));
958 const mag = new Float32Array(frames * bins);
959 const re = new Float64Array(N), im = new Float64Array(N);
960 let mx = -Infinity;
961 for (let f = 0; f < frames; f++) {
962 for (let i = 0; i < N; i++) { re[i] = sig[f * hop + i] * win[i]; im[i] = 0; }
963 fft(re, im);
964 for (let b = 0; b < bins; b++) {
965 const v = 10 * Math.log10(re[b] * re[b] + im[b] * im[b] + 1e-20);
966 mag[f * bins + b] = v; if (v > mx) mx = v;
967 }
968 }
969 S.spec = { mag, frames, bins, mx };
970 return S.spec;
971 }
972 function drawSpec() {
973 const { ctx, W, H } = setup($("cSpec"));
974 const ax = axesTime(ctx, W, H, 0, 1, [], () => "", params.dur);
975 const S = strikeAt(sel) || A;
976 if (!S || !S.sig) return;
977 const mx = spectrogramOf(A).mx;
978 const { mag, frames, bins } = spectrogramOf(S);
979 const off = document.createElement("canvas");
980 off.width = frames; off.height = bins;
981 const octx = off.getContext("2d");
982 const img = octx.createImageData(frames, bins);
983 const ink = hexToRgb(css("--ink")), bg = hexToRgb(css("--panel")), hot = hexToRgb(css("--chaos"));
984 for (let f = 0; f < frames; f++) for (let b = 0; b < bins; b++) {
985 const v = Math.min(1, Math.max(0, (mag[f * bins + b] - (mx - 70)) / 70));
986 const c = v < 0.75 ? mix(bg, ink, v / 0.75) : mix(ink, hot, (v - 0.75) / 0.25);
987 const o = ((bins - 1 - b) * frames + f) * 4;
988 img.data[o] = c[0]; img.data[o + 1] = c[1]; img.data[o + 2] = c[2]; img.data[o + 3] = 255;
989 }
990 octx.putImageData(img, 0, 0);
991 const tEnd = (frames * 256 + 1024) / SR;
992 ctx.imageSmoothingEnabled = true;
993 ctx.drawImage(off, ax.x0 + 1024 / SR / params.dur * (ax.x1 - ax.x0), ax.y0, (tEnd - 1024 / SR) / params.dur * (ax.x1 - ax.x0), ax.y1 - ax.y0);
994 // frequency labels
995 ctx.fillStyle = css("--muted"); ctx.font = "11px " + css("--mono"); ctx.textAlign = "right"; ctx.textBaseline = "middle";
996 const step = niceStep(specFmax / 4);
997 for (let f = 0; f <= specFmax; f += step) {
998 const y = ax.y1 - f / specFmax * (ax.y1 - ax.y0);
999 ctx.fillText(f >= 1000 ? fmt(f / 1000, f % 1000 ? 1 : 0) + "k" : String(f), ax.x0 - 6, y);
1000 }
1001 const ks = strikeAt(sel) ? sel : 0;
1002 ctx.fillStyle = strikeColor(ks); ctx.font = "600 12px " + css("--sans"); ctx.textAlign = "right"; ctx.textBaseline = "top";
1003 ctx.fillText("strike " + strikeName(ks), ax.x1 - 6, ax.y0 + 4);
1004 }
1005 function hexToRgb(h) {
1006 h = h.replace("#", "");
1007 if (h.length === 3) h = h.split("").map((c) => c + c).join("");
1008 const n = parseInt(h, 16);
1009 return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
1010 }
1011 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]; }
1013 function drawAll() {
1014 if (!params) return;
1015 drawSetup(); drawPath(); drawSection(); drawShare(); drawLam(); drawSpec(); drawOverlays(null, null);
1016 }
1018 // ---------------- audio ----------------
1019 let actx = null, bufs = [], src = null, playStart = 0, playing = null, raf = 0, audioGain = 1;
1020 function scaleAudio(s) {
1021 const out = new Float32Array(s.length), fade = Math.round(0.01 * SR);
1022 for (let i = 0; i < s.length; i++) {
1023 const tail = s.length - 1 - i;
1024 out[i] = Math.max(-1, Math.min(1, s[i] * audioGain * (tail < fade ? tail / fade : 1)));
1025 }
1026 return out;
1027 }
1028 function prepareAudio() {
1029 let m = 0;
1030 for (const s of [A.sig, B.sig]) for (let i = 0; i < s.length; i++) m = Math.max(m, Math.abs(s[i]));
1031 audioGain = 0.8 / (m || 1); // one gain for every strike, so loudness differences are real
1032 A.audio = scaleAudio(A.sig); B.audio = scaleAudio(B.sig);
1033 bufs = [];
1034 }
1035 function ensureCtx() {
1036 if (!actx) actx = new (window.AudioContext || window.webkitAudioContext)();
1037 if (actx.state === "suspended") actx.resume();
1038 }
1039 function toBuffer(data) {
1040 const b = actx.createBuffer(1, data.length, SR);
1041 b.copyToChannel(data, 0);
1042 return b;
1043 }
1044 function stopAudio() {
1045 if (src) { try { src.onended = null; src.stop(); } catch (e) {} src = null; }
1046 playing = null; cancelAnimationFrame(raf);
1047 for (const id of ["playA", "playB", "playAB", "playSel"]) if ($(id)) $(id).setAttribute("aria-pressed", "false");
1048 drawOverlays(null, null);
1049 }
1050 function play(k, then) {
1051 const S = strikeAt(k);
1052 if (!S || !S.audio) return;
1053 ensureCtx();
1054 stopAudio();
1055 if (!bufs[k]) bufs[k] = toBuffer(S.audio);
1056 src = actx.createBufferSource();
1057 src.buffer = bufs[k];
1058 src.connect(actx.destination);
1059 const next = then === null || then === undefined ? null : then;
1060 src.onended = () => {
1061 src = null;
1062 if (next !== null) setTimeout(() => play(next, null), 350);
1063 else stopAudio();
1064 };
1065 playStart = actx.currentTime;
1066 playing = k;
1067 src.start();
1068 if (k === 0) $("playA").setAttribute("aria-pressed", "true");
1069 else if (k === 1) $("playB").setAttribute("aria-pressed", "true");
1070 if (k === sel && $("playSel")) $("playSel").setAttribute("aria-pressed", "true");
1071 if (next !== null) $("playAB").setAttribute("aria-pressed", "true");
1072 tick();
1073 }
1074 function tick() {
1075 if (playing === null) return;
1076 const t = actx.currentTime - playStart;
1077 drawOverlays(t, playing);
1078 raf = requestAnimationFrame(tick);
1079 }
1081 function drawOverlays(t, which) {
1082 for (const [id, base] of [["cShareO", "cShare"], ["cLamO", "cLam"], ["cSpecO", "cSpec"]]) {
1083 const { ctx, W } = setup($(id));
1084 if (t === null) continue;
1085 const H = $(base).getBoundingClientRect().height;
1086 const x = PAD.l + Math.min(t, params.dur) / params.dur * (W - PAD.r - PAD.l);
1087 ctx.strokeStyle = strikeColor(which); ctx.lineWidth = 2;
1088 ctx.beginPath(); ctx.moveTo(x, PAD.t); ctx.lineTo(x, H - PAD.b); ctx.stroke();
1089 }
1090 const { ctx, W, H } = setup($("cPathO"));
1091 if (t === null || !A) return;
1092 const S = strikeAt(which);
1093 if (!S || !S.xs) return;
1094 const i1 = Math.min(S.xs.length - 1, Math.floor(t * SR));
1095 const i0 = Math.max(0, i1 - Math.round(0.03 * SR));
1096 ctx.strokeStyle = strikeColor(which); ctx.lineWidth = 1.6;
1097 ctx.beginPath();
1098 ctx.moveTo(W / 2 + S.xs[i0] * pathScale, H / 2 - S.ys[i0] * pathScale);
1099 for (let i = i0 + 1; i <= i1; i++) ctx.lineTo(W / 2 + S.xs[i] * pathScale, H / 2 - S.ys[i] * pathScale);
1100 ctx.stroke();
1101 ctx.fillStyle = css("--chaos");
1102 ctx.beginPath(); ctx.arc(W / 2 + S.xs[i1] * pathScale, H / 2 - S.ys[i1] * pathScale, 4, 0, 2 * Math.PI); ctx.fill();
1103 }
1105 // ---------------- wiring ----------------
1106 $("playA").addEventListener("click", () => play(0, null));
1107 $("playB").addEventListener("click", () => play(1, null));
1108 $("playAB").addEventListener("click", () => play(0, 1));
1109 $("stop").addEventListener("click", stopAudio);
1110 $("slow").addEventListener("click", () => (smOn ? stopSlow() : startSlow()));
1111 let timer = 0;
f7cb0afWeb app: listener direction or sum, layout fix; link the gong-wire and fput-plate toysJeremy Magland 1112 for (const [id, other] of [["lisOne", "lisSum"], ["lisSum", "lisOne"]]) $(id).addEventListener("click", () => {
1113 if ($(id).getAttribute("aria-pressed") === "true") return;
1114 $(id).setAttribute("aria-pressed", "true"); $(other).setAttribute("aria-pressed", "false"); readParams(); run();
1115 });
1116 for (const id of ["lis", "E0", "w2", "ang", "dl", "t60", "f1", "dur"]) {
da03127Two-oscillator chaos toy: model, experiments, figures, web appJeremy Magland 1117 $(id).addEventListener("input", () => { readParams(); clearTimeout(timer); timer = setTimeout(run, 250); });
1118 }
1119 document.querySelectorAll("[data-preset]").forEach((b) => b.addEventListener("click", () => {
1120 setControls(PRESETS[b.dataset.preset]); run();
1121 }));
1122 let rt = 0;
1123 window.addEventListener("resize", () => { clearTimeout(rt); rt = setTimeout(drawAll, 120); });
1124 try {
1125 window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", drawAll);
1126 new MutationObserver(drawAll).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
1127 } catch (e) {}
1129 setControls(PRESETS.strong);
1130 run();
1131})();
1132</script>