Web app: listener direction or sum, layout fix; link the gong-wire and fput-plate toys
2 changed files+47−3
README.mdmodified+2−0View file
@@ -14,6 +14,8 @@ Structurally the plate in modal form is a set of oscillators coupled through a q
1414 | --- | --- | --- |
1515 | `two-oscillators/` | two oscillators with a quartic coupling, $H = \tfrac12 (p_x^2 + p_y^2) + \tfrac12 (\omega_1^2 x^2 + \omega_2^2 y^2) + \beta x^2 y^2$ | the mechanism: how regular motion breaks up as the energy grows (Poincare sections, Lyapunov exponent vs energy), and why damping freezes a random outcome |
1616 | `../fput-chain` (separate project) | the Fermi-Pasta-Ulam-Tsingou beta chain, struck, with frequency-dependent damping | the many-mode phenomena: energy cascade or not, threshold, the frozen spectrum, listening; web app at https://claude.ai/artifact/C6oaNiG2zCRX9VR7Wn4Wfx |
17+| `../gong-wire` (separate project) | the FPUT chain with bending stiffness and a dome: beads on a stiff, curved wire | a gong-like cascade from 100 Hz to 17 kHz, and how bloom differs from chaos; web app at https://claude.ai/artifact/RqEkYpuX4wqHXT7Rcfs3ZU |
18+| `../fput-plate` (separate project) | the same in two dimensions: a 24 x 24 sheet of beads with stretching links, bending and a dome | a cymbal-like dense high band (modes per octave grow with pitch); bloom into it, and when chaos appears; web app at https://claude.ai/artifact/UeCkZbQqcbzpk3e4kvCmr5 |
1719
1820 With $\beta$ fixed, rescaling the variables by $\sqrt{E}$ shows that the two-oscillator dynamics depends on $\beta E$ only, the analogue of the plate's strike strength (or peak deflection over thickness).
1921
web/index.htmlmodified+45−3View file
@@ -83,6 +83,7 @@ aside {
8383 border-radius: 6px;
8484 padding: 16px;
8585 display: grid;
86+ grid-template-columns: minmax(0, 1fr);
8687 gap: 16px;
8788 position: sticky;
8889 top: calc(env(safe-area-inset-top, 0px) + 12px);
@@ -107,6 +108,8 @@ aside {
107108 cursor: pointer;
108109 }
109110 .chip:hover { border-color: var(--muted); }
111+.chip[aria-pressed="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }
112+input[type="range"]:disabled { opacity: 0.4; }
110113 .field { display: grid; gap: 4px; }
111114 .field .row { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
112115 .field label { font-size: 14px; font-weight: 550; }
@@ -169,6 +172,10 @@ figcaption span { font-size: 13px; color: var(--muted); max-width: 80ch; }
169172 footer { color: var(--muted); font-size: 13px; max-width: 80ch; }
170173 footer p { margin: 0 0 8px; }
171174 @media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } }
175+/* keep the control column inside its panel whatever the readouts say */
176+aside > * { min-width: 0; }
177+aside .field .row { flex-wrap: wrap; }
178+aside .val { white-space: normal; text-align: right; overflow-wrap: anywhere; }
172179 </style>
173180
174181 <div class="wrap">
@@ -192,6 +199,16 @@ footer p { margin: 0 0 8px; }
192199 <div class="status" id="status">Computing…</div>
193200 </div>
194201
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>
211+
195212 <div class="field">
196213 <div class="label">Presets</div>
197214 <div class="presets">
@@ -280,7 +297,7 @@ footer p { margin: 0 0 8px; }
280297 </figure>
281298
282299 <figure>
283- <figcaption><b>What you hear</b><span>Spectrogram of the selected strike (pickup p<sub>x</sub> + p<sub>y</sub>). 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>
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>
284301 <div class="strikes" role="group" aria-label="Choose a strike" id="strikeSel"></div>
285302 <div class="plot wide"><canvas id="cSpec"></canvas><canvas id="cSpecO"></canvas></div>
286303 </figure>
@@ -344,7 +361,7 @@ function strike(p, angle, withTangent, audioRate) {
344361 const n = Math.hypot(dx, dy, dpx, dpy);
345362 L += Math.log(n); dx /= n; dy /= n; dpx /= n; dpy /= n;
346363 }
347- if (audioRate) { sig[i] = px + py; xs[i] = x; ys[i] = y; }
364+ if (audioRate) { sig[i] = p.lx * px + p.ly * py; xs[i] = x; ys[i] = y; }
348365 if ((i + 1) % K === 0) {
349366 const j = (i + 1) / K - 1;
350367 if (j < nD) {
@@ -453,12 +470,16 @@ onmessage = (ev) => {
453470 $("t60v").textContent = fmt(t60, 1) + " s";
454471 $("f1v").textContent = Math.round(f1) + " Hz (y: " + Math.round(f1 * w2) + " Hz)";
455472 $("durv").textContent = fmt(dur, 1) + " s";
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;
456476 const tau = 1 / (2 * Math.PI * f1); // seconds per time unit
457477 const nOut = Math.round(dur * SR);
458478 return {
459479 E0, w2, angle: angDeg * Math.PI / 180, delta: Math.pow(10, dl), tau,
460480 // momenta damped by exp(-gamma dt): energy decays at rate gamma, amplitude at gamma / 2
461481 gamma: 2 * Math.log(1000) / (t60 / tau), dtOut: 1 / SR / tau, nOut, decim: Math.round(SR / 400), dur,
482+ lx: both ? 1 : Math.cos(th), ly: both ? 1 : Math.sin(th), listenBoth: both, listenAngle: th,
462483 };
463484 }
464485
@@ -821,6 +842,23 @@ onmessage = (ev) => {
821842 ctx.fillStyle = ink; ctx.font = "600 12px " + css("--sans"); ctx.textAlign = "left"; ctx.textBaseline = "bottom";
822843 ctx.fillText("strike, E = " + (params.E0 < 10 ? fmt(params.E0, 2) : fmt(params.E0, 1)), ex + 8, ey - 2);
823844 }
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+ }
824862 // stiffness readouts
825863 ctx.font = "11px " + css("--mono"); ctx.textBaseline = "top";
826864 ctx.fillStyle = css("--x"); ctx.textAlign = "left";
@@ -1071,7 +1109,11 @@ onmessage = (ev) => {
10711109 $("stop").addEventListener("click", stopAudio);
10721110 $("slow").addEventListener("click", () => (smOn ? stopSlow() : startSlow()));
10731111 let timer = 0;
1074- for (const id of ["E0", "w2", "ang", "dl", "t60", "f1", "dur"]) {
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"]) {
10751117 $(id).addEventListener("input", () => { readParams(); clearTimeout(timer); timer = setTimeout(run, 250); });
10761118 }
10771119 document.querySelectorAll("[data-preset]").forEach((b) => b.addEventListener("click", () => {