/ proof-of-concept / fput-chain
Sign in
proof-of-concept / fput-chain
Web app: listener mass or sum, layout fix; link gong-wire
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 14b52adbeb7c parent 70cc577 Browse files
2 changed files+40−11
README.mdmodified+2−0View file
@@ -1,5 +1,7 @@
11 # fput-chain
22
3+(See also `../gong-wire`, which adds bending stiffness and a dome to this chain to get a gong-like cascade to high frequencies.)
4+
35 A struck Fermi-Pasta-Ulam-Tsingou chain: the many-mode companion of the two-oscillator toy in `../chaos-toy-examples`, and a simple stand-in for the struck plate of `cymbal-elastodynamics/flat-plate-2`.
46
57 ## The model
web/index.htmlmodified+38−11View file
@@ -43,12 +43,14 @@ h1 .eq { font-family: var(--mono); font-weight: 400; font-size: 0.42em; color: v
4343 .lede { max-width: 74ch; color: var(--muted); margin: 0; }
4444 .layout { display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 20px; align-items: start; }
4545 @media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
46-aside { background: var(--panel); border: 1px solid var(--rule); border-radius: 6px; padding: 16px; display: grid; gap: 16px; position: sticky; top: calc(env(safe-area-inset-top, 0px) + 12px); }
46+aside { 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); }
4747 @media (max-width: 900px) { aside { position: static; } }
4848 .label { font-size: 11px; font-weight: 650; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }
4949 .presets { display: flex; flex-wrap: wrap; gap: 6px; }
5050 .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; }
5151 .chip:hover { border-color: var(--muted); }
52+.chip[aria-pressed="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }
53+input[type="range"]:disabled { opacity: 0.4; }
5254 .field { display: grid; gap: 4px; }
5355 .field .row { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
5456 .field label { font-size: 14px; font-weight: 550; }
@@ -93,6 +95,10 @@ figcaption span { font-size: 13px; color: var(--muted); max-width: 80ch; }
9395 .legend i { display: inline-block; width: 18px; height: 3px; vertical-align: middle; margin-right: 6px; border-radius: 2px; }
9496 footer { color: var(--muted); font-size: 13px; max-width: 80ch; }
9597 footer p { margin: 0 0 8px; }
98+/* keep the control column inside its panel whatever the readouts say */
99+aside > * { min-width: 0; }
100+aside .field .row { flex-wrap: wrap; }
101+aside .val { white-space: normal; text-align: right; overflow-wrap: anywhere; }
96102 </style>
97103
98104 <div class="wrap">
@@ -116,6 +122,16 @@ footer p { margin: 0 0 8px; }
116122 <div class="status" id="status">Computing…</div>
117123 </div>
118124
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>
134+
119135 <div class="field">
120136 <div class="label">Presets</div>
121137 <div class="presets">
@@ -183,7 +199,7 @@ footer p { margin: 0 0 8px; }
183199 <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>
184200 <div class="plot chainplot"><canvas id="cChain"></canvas></div>
185201 <div class="setuptext">
186- <p>The springs push back with f(Δ) = Δ + βΔ³ when stretched by Δ, so each one stiffens as it stretches: its stiffness is <span class="k">1 + 3βΔ²</span> (line thickness). That is what couples the modes, exactly as stretching the midplane couples the modes of a plate. The strike gives the marked masses a sudden velocity; the sound is the velocity of the mass at the pickup ring.</p>
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>
187203 <button class="btn" id="slow" type="button" aria-pressed="false">Slow motion</button>
188204 </div>
189205 </figure>
@@ -211,7 +227,7 @@ footer p { margin: 0 0 8px; }
211227 </figure>
212228
213229 <figure>
214- <figcaption><b>What you hear</b><span>Spectrogram of the selected strike at the pickup. Click a strike, or use the ← and → keys, to flip between them; the color scale is shared, so differences in level are real. The selected strike is also drawn bold in the two plots above.</span></figcaption>
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>
215231 <div class="strikes" role="group" aria-label="Choose a strike" id="strikeSel"></div>
216232 <div class="plot wide"><canvas id="cSpec"></canvas><canvas id="cSpecO"></canvas></div>
217233 </figure>
@@ -328,7 +344,7 @@ function strikeRun(p, ch, scale, opts) {
328344 L += Math.log(normalize(du, dv));
329345 tforce(u, du, g);
330346 }
331- if (sig) sig[i] = v[pk];
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]; }
332348 if (i < snapN) for (let j = 0; j < N; j++) snap[i * N + j] = u[j];
333349 if ((i + 1) % K === 0) {
334350 const jd = (i + 1) / K - 1;
@@ -388,6 +404,8 @@ onmessage = (ev) => {
388404 const center = Math.max(1, Math.round(pos * (N + 1)));
389405 $("E0v").textContent = (E0 < 10 ? fmt(E0, 2) : fmt(E0, 1)) + " (" + fmt(E0 / N, 3) + " per mass)";
390406 $("Nv").textContent = String(N);
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; }
391409 $("muv").textContent = fmt(mu, 2);
392410 $("posv").textContent = "mass " + center + " of " + N;
393411 $("widv").textContent = fmt(wid, 1) + " masses";
@@ -405,7 +423,7 @@ onmessage = (ev) => {
405423 const nOut = Math.round(dur * SR);
406424 return {
407425 E0, N, mu, center, width: wid, delta: Math.pow(10, dl), tau, c0, c1, dur, f1, omN, om1,
408- dtOut: 1 / SR / tau, nOut, decim: Math.round(SR / 400), pickup: Math.max(1, Math.round(0.38 * (N + 1))),
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",
409427 hmax: Math.min(0.2, 0.45 / Math.sqrt(4 + mu * mu + 12 * E0)), snapN: Math.round(0.12 * SR),
410428 };
411429 }
@@ -636,12 +654,17 @@ onmessage = (ev) => {
636654 for (let j = 0; j < N; j++) {
637655 ctx.fillStyle = ink; ctx.beginPath(); ctx.arc(px(j), py(j), r, 0, 2 * Math.PI); ctx.fill();
638656 }
639- // pickup ring
657+ // listener ring(s)
640658 const pk = params.pickup - 1;
641- ctx.strokeStyle = css("--y"); ctx.lineWidth = 2;
642- ctx.beginPath(); ctx.arc(px(pk), py(pk), r + 5, 0, 2 * Math.PI); ctx.stroke();
643- ctx.fillStyle = css("--y"); ctx.font = "11px " + css("--mono"); ctx.textAlign = "center"; ctx.textBaseline = "top";
644- ctx.fillText("pickup", px(pk), H - 18);
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+ }
645668 // strike arrows (velocity profile) when at rest
646669 if (!smOn) {
647670 ctx.strokeStyle = css("--chaos"); ctx.fillStyle = css("--chaos"); ctx.lineWidth = 2.5;
@@ -856,9 +879,13 @@ onmessage = (ev) => {
856879 $("playB").addEventListener("click", () => play(1, null));
857880 $("playAB").addEventListener("click", () => play(0, 1));
858881 $("stop").addEventListener("click", stopAudio);
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+ });
859886 $("slow").addEventListener("click", () => (smOn ? stopSlow() : startSlow()));
860887 let timer = 0;
861- for (const id of ["E0", "N", "mu", "pos", "wid", "dl", "tlo", "thi", "f1", "dur"]) {
888+ for (const id of ["lis", "E0", "N", "mu", "pos", "wid", "dl", "tlo", "thi", "f1", "dur"]) {
862889 $(id).addEventListener("input", () => { readParams(); clearTimeout(timer); timer = setTimeout(run, 300); });
863890 }
864891 document.querySelectorAll("[data-preset]").forEach((b) => b.addEventListener("click", () => { setControls(PRESETS[b.dataset.preset]); run(); }));
↑↓ move↵openescclose